diff --git a/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt b/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt index 35f87f5f69..cbda1a54e4 100644 --- a/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt +++ b/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt @@ -35,6 +35,61 @@ data class SubmissionIdMapping( fun List.toPairs() = map { Pair(it.accession, it.version) } +data class AccessionVersions( + val accessionVersions: List, +) + +@Schema( + description = "If set to 'INCLUDE_WARNINGS', sequence entries with warnings are included in the response." + + " If set to 'EXCLUDE_WARNINGS', sequence entries with warnings are not included in the response. " + + "Default is 'INCLUDE_WARNINGS'.", +) +enum class WarningsFilter { + EXCLUDE_WARNINGS, + INCLUDE_WARNINGS, +} + +enum class DeleteSequenceScope { + ALL, + PROCESSED_WITH_ERRORS, + PROCESSED_WITH_WARNINGS, +} + +const val ACCESSION_VERSIONS_FILTER_DESCRIPTION = + "A List of accession versions that the operation will be restricted to. " + + "Omit or set to null to consider all sequences." + +data class AccessionVersionsFilterWithDeletionScope( + @Schema( + description = ACCESSION_VERSIONS_FILTER_DESCRIPTION, + ) + val accessionVersionsFilter: List? = null, + @Schema( + description = "Scope for deletion. If scope is set to 'ALL', all sequences are deleted. " + + "If scope is set to 'PROCESSED_WITH_ERRORS', only processed sequences with errors are deleted. " + + "If scope is set to 'PROCESSED_WITH_WARNINGS', only processed sequences in `AWAITING_APPROVAL` " + + "with warnings are deleted.", + ) + val scope: DeleteSequenceScope, +) + +enum class ApproveDataScope { + ALL, + WITHOUT_WARNINGS, +} + +data class AccessionVersionsFilterWithApprovalScope( + @Schema( + description = ACCESSION_VERSIONS_FILTER_DESCRIPTION, + ) + val accessionVersionsFilter: List? = null, + @Schema( + description = "Scope for approval. If scope is set to 'ALL', all sequences are approved. " + + "If scope is set to 'WITHOUT_WARNINGS', only sequences without warnings are approved.", + ) + val scope: ApproveDataScope, +) + data class SubmittedProcessedData( override val accession: Accession, override val version: Version, diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt index 8b68132bce..76e23bbf6c 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt @@ -11,6 +11,9 @@ import jakarta.validation.Valid import jakarta.validation.constraints.Max import mu.KotlinLogging import org.loculus.backend.api.AccessionVersion +import org.loculus.backend.api.AccessionVersions +import org.loculus.backend.api.AccessionVersionsFilterWithApprovalScope +import org.loculus.backend.api.AccessionVersionsFilterWithDeletionScope import org.loculus.backend.api.Accessions import org.loculus.backend.api.DataUseTerms import org.loculus.backend.api.DataUseTermsType @@ -22,6 +25,7 @@ import org.loculus.backend.api.Status import org.loculus.backend.api.SubmissionIdMapping import org.loculus.backend.api.SubmittedProcessedData import org.loculus.backend.api.UnprocessedData +import org.loculus.backend.api.WarningsFilter import org.loculus.backend.model.ReleasedDataModel import org.loculus.backend.model.SubmissionParams import org.loculus.backend.model.SubmitModel @@ -252,6 +256,8 @@ class SubmissionController( @RequestParam(required = false) statusesFilter: List?, @UsernameFromJwt username: String, + @RequestParam(required = false, defaultValue = "INCLUDE_WARNINGS") + warningsFilter: WarningsFilter, @Parameter( description = "Part of pagination parameters. Page number starts from 0. " + "If page or size are not provided, all sequences are returned.", @@ -264,8 +270,15 @@ class SubmissionController( ) @RequestParam(required = false) size: Int?, - ): GetSequenceResponse = - submissionDatabaseService.getSequences(username, organism, groupsFilter, statusesFilter, page, size) + ): GetSequenceResponse = submissionDatabaseService.getSequences( + username, + organism, + groupsFilter, + statusesFilter, + warningsFilter, + page, + size, + ) @Operation(description = APPROVE_PROCESSED_DATA_DESCRIPTION) @ResponseStatus(HttpStatus.NO_CONTENT) @@ -274,9 +287,15 @@ class SubmissionController( @PathVariable @Valid organism: Organism, @UsernameFromJwt username: String, - @RequestBody body: AccessionVersions, + @RequestBody + body: AccessionVersionsFilterWithApprovalScope, ) { - submissionDatabaseService.approveProcessedData(username, body.accessionVersions, organism) + submissionDatabaseService.approveProcessedData( + submitter = username, + accessionVersionsFilter = body.accessionVersionsFilter, + organism = organism, + scope = body.scope, + ) } @Operation(description = REVOKE_DESCRIPTION) @@ -299,7 +318,7 @@ class SubmissionController( ) = submissionDatabaseService.confirmRevocation(body.accessionVersions, username, organism) @Operation(description = DELETE_SEQUENCES_DESCRIPTION) - @ResponseStatus(HttpStatus.NO_CONTENT) + @ResponseStatus(HttpStatus.OK) @DeleteMapping( "/delete-sequence-entry-versions", ) @@ -307,8 +326,14 @@ class SubmissionController( @PathVariable @Valid organism: Organism, @UsernameFromJwt username: String, - @RequestBody body: AccessionVersions, - ) = submissionDatabaseService.deleteSequenceEntryVersions(body.accessionVersions, username, organism) + @RequestBody + body: AccessionVersionsFilterWithDeletionScope, + ): List = submissionDatabaseService.deleteSequenceEntryVersions( + body.accessionVersionsFilter, + username, + organism, + body.scope, + ) private fun stream(sequenceProvider: () -> Sequence) = StreamingResponseBody { outputStream -> try { @@ -320,10 +345,6 @@ class SubmissionController( ) } } - - data class AccessionVersions( - val accessionVersions: List, - ) } @Target(AnnotationTarget.VALUE_PARAMETER) diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt index 205704ea6a..7db5253e7a 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt @@ -9,6 +9,7 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.alias import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.json.exists import org.jetbrains.exposed.sql.json.jsonb import org.jetbrains.exposed.sql.kotlin.datetime.datetime import org.jetbrains.exposed.sql.max @@ -81,8 +82,6 @@ class SequenceEntriesDataTable( ) } - val isMaxReleasedVersion = versionColumn eq maxReleasedVersionQuery() - private fun maxReleasedVersionQuery(): Expression { val subQueryTable = alias("subQueryTable") return wrapAsExpression( @@ -100,6 +99,8 @@ class SequenceEntriesDataTable( fun organismIs(organism: Organism) = organismColumn eq organism.name + val entriesWithWarnings = warningsColumn.exists("[0]") + fun statusIs(status: Status) = statusColumn eq status.name fun statusIsOneOf(statuses: List) = statusColumn inList statuses.map { it.name } diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt index dbe49ffb40..b58f84de15 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt @@ -11,6 +11,7 @@ import kotlinx.datetime.toLocalDateTime import mu.KotlinLogging import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.JoinType +import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SqlExpressionBuilder.plus import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.andWhere @@ -19,13 +20,16 @@ import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.kotlin.datetime.dateTimeParam import org.jetbrains.exposed.sql.max +import org.jetbrains.exposed.sql.not import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.stringParam import org.jetbrains.exposed.sql.update import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.AccessionVersionInterface +import org.loculus.backend.api.ApproveDataScope import org.loculus.backend.api.DataUseTerms import org.loculus.backend.api.DataUseTermsType +import org.loculus.backend.api.DeleteSequenceScope import org.loculus.backend.api.GetSequenceResponse import org.loculus.backend.api.Group import org.loculus.backend.api.Organism @@ -42,6 +46,7 @@ import org.loculus.backend.api.Status.RECEIVED import org.loculus.backend.api.SubmissionIdMapping import org.loculus.backend.api.SubmittedProcessedData import org.loculus.backend.api.UnprocessedData +import org.loculus.backend.api.WarningsFilter import org.loculus.backend.controller.BadRequestException import org.loculus.backend.controller.ProcessingValidationException import org.loculus.backend.controller.UnprocessableEntityException @@ -217,26 +222,58 @@ class SubmissionDatabaseService( } } - fun approveProcessedData(submitter: String, accessionVersions: List, organism: Organism) { - log.info { "approving ${accessionVersions.size} sequences by $submitter" } + fun approveProcessedData( + submitter: String, + accessionVersionsFilter: List?, + organism: Organism, + scope: ApproveDataScope, + ) { + if (accessionVersionsFilter == null) { + log.info { "approving all sequences by all groups $submitter is member of" } + } else { + log.info { "approving ${accessionVersionsFilter.size} sequences by $submitter" } + } val now = Clock.System.now().toLocalDateTime(TimeZone.UTC) - accessionPreconditionValidator.validateAccessionVersions( - submitter, - accessionVersions, - listOf(AWAITING_APPROVAL), - organism, - ) + if (accessionVersionsFilter != null) { + accessionPreconditionValidator.validateAccessionVersions( + submitter, + accessionVersionsFilter, + listOf(AWAITING_APPROVAL), + organism, + ) + } sequenceEntriesTableProvider.get(organism).let { table -> - table.update( + table.join( + DataUseTermsTable, + JoinType.LEFT, + additionalConstraint = { + (table.accessionColumn eq DataUseTermsTable.accessionColumn) and + (DataUseTermsTable.isNewestDataUseTerms) + }, + ).update( where = { - table.accessionVersionIsIn(accessionVersions) and table.statusIs(AWAITING_APPROVAL) + val statusCondition = table.statusIs(AWAITING_APPROVAL) + + val accessionCondition = if (accessionVersionsFilter !== null) { + table.accessionVersionIsIn(accessionVersionsFilter) + } else { + table.groupIsOneOf(groupManagementDatabaseService.getGroupsOfUser(submitter)) + } + + val scopeCondition = if (scope == ApproveDataScope.WITHOUT_WARNINGS) { + not(table.entriesWithWarnings) + } else { + Op.TRUE + } + + statusCondition and accessionCondition and scopeCondition }, ) { - it[statusColumn] = APPROVED_FOR_RELEASE.name - it[releasedAtColumn] = now + it[table.statusColumn] = APPROVED_FOR_RELEASE.name + it[table.releasedAtColumn] = now } } } @@ -364,6 +401,7 @@ class SubmissionDatabaseService( organism: Organism?, groupsFilter: List?, statusesFilter: List?, + warningsFilter: WarningsFilter? = null, page: Int? = null, size: Int? = null, ): GetSequenceResponse { @@ -419,6 +457,12 @@ class SubmissionDatabaseService( query.count { it[table.statusColumn] == status.name } } + if (warningsFilter == WarningsFilter.EXCLUDE_WARNINGS) { + query.andWhere { + not(table.entriesWithWarnings) + } + } + val pagedQuery = if (page != null && size != null) { query.limit(size, (page * size).toLong()) } else { @@ -540,19 +584,66 @@ class SubmissionDatabaseService( } } - fun deleteSequenceEntryVersions(accessionVersions: List, submitter: String, organism: Organism) { - log.info { "Deleting accession versions: $accessionVersions" } + fun deleteSequenceEntryVersions( + accessionVersionsFilter: List?, + submitter: String, + organism: Organism, + scope: DeleteSequenceScope, + ): List { + if (accessionVersionsFilter == null) { + log.info { "deleting all sequences of all groups $submitter is member of in the scope $scope" } + } else { + log.info { "deleting ${accessionVersionsFilter.size} sequences by $submitter in scope $scope" } + } - accessionPreconditionValidator.validateAccessionVersions( - submitter, - accessionVersions, - listOf(RECEIVED, AWAITING_APPROVAL, HAS_ERRORS, AWAITING_APPROVAL_FOR_REVOCATION), - organism, - ) + val listOfDeletableStatuses = listOf(RECEIVED, AWAITING_APPROVAL, HAS_ERRORS, AWAITING_APPROVAL_FOR_REVOCATION) + + if (accessionVersionsFilter != null) { + accessionPreconditionValidator.validateAccessionVersions( + submitter, + accessionVersionsFilter, + listOfDeletableStatuses, + organism, + ) + } + + val sequenceEntriesToDelete = sequenceEntriesTableProvider.get(organism).let { table -> + table.join( + DataUseTermsTable, + JoinType.LEFT, + additionalConstraint = { + (table.accessionColumn eq DataUseTermsTable.accessionColumn) and + (DataUseTermsTable.isNewestDataUseTerms) + }, + ).select { + val accessionCondition = if (accessionVersionsFilter != null) { + table.accessionVersionIsIn(accessionVersionsFilter) + } else { + table.groupIsOneOf(groupManagementDatabaseService.getGroupsOfUser(submitter)) + } + + val scopeCondition = when (scope) { + DeleteSequenceScope.PROCESSED_WITH_ERRORS -> { + table.statusIs(HAS_ERRORS) + } + DeleteSequenceScope.PROCESSED_WITH_WARNINGS -> { + table.statusIs(AWAITING_APPROVAL) and + table.entriesWithWarnings + } + DeleteSequenceScope.ALL -> table.statusIsOneOf(listOfDeletableStatuses) + } + + accessionCondition and scopeCondition + }.map { + AccessionVersion(it[table.accessionColumn], it[table.versionColumn]) + } + } sequenceEntriesTableProvider.get(organism).deleteWhere { - accessionVersionIsIn(accessionVersions) + accessionVersionIsIn(sequenceEntriesToDelete) } + + return sequenceEntriesToDelete } fun submitEditedData(submitter: String, editedAccessionVersion: UnprocessedData, organism: Organism) { diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/ExceptionHandlerTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/ExceptionHandlerTest.kt index eff49858cf..a45dd6e562 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/ExceptionHandlerTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/ExceptionHandlerTest.kt @@ -102,14 +102,13 @@ class ExceptionHandlerTest(@Autowired val mockMvc: MockMvc) { post(addOrganismToPath("/approve-processed-data")) .param("username", "userName") .contentType(MediaType.APPLICATION_JSON) - .content("""{"fieldThatDoesNotExist": null}""") - .param("groupName", "testGroup") + .content("""{"fieldThatIsDefinitelyNotScopeWhichIsRequired": null}""") .withAuth(), ) .andExpect(status().isBadRequest) .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.title").value("Bad Request")) - .andExpect(jsonPath("$.detail", containsString("failed for JSON property accessionVersions"))) + .andExpect(jsonPath("$.detail", containsString("for creator parameter scope which is a non-nullable type"))) } } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt index cd4e20c6f3..e8aae3c46d 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt @@ -1,8 +1,11 @@ package org.loculus.backend.controller.submission +import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.hasSize import org.junit.jupiter.api.Test import org.loculus.backend.api.AccessionVersion +import org.loculus.backend.api.ApproveDataScope import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE import org.loculus.backend.api.Status.AWAITING_APPROVAL import org.loculus.backend.api.Status.IN_PROCESSING @@ -13,6 +16,7 @@ import org.loculus.backend.controller.assertStatusIs import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.generateJwtFor import org.loculus.backend.controller.getAccessionVersions +import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -56,6 +60,19 @@ class ApproveProcessedDataEndpointTest( .assertStatusIs(APPROVED_FOR_RELEASE) } + @Test + fun `WHEN I approve without accession filter or with full scope THEN all data is approved`() { + convenienceClient.prepareDataTo(AWAITING_APPROVAL) + + client.approveProcessedSequenceEntries(scope = ApproveDataScope.ALL) + .andExpect(status().isNoContent) + + assertThat( + convenienceClient.getSequenceEntriesOfUserInState(status = APPROVED_FOR_RELEASE), + hasSize(NUMBER_OF_SEQUENCES), + ) + } + @Test fun `WHEN I approve sequence entries as non-group member THEN it should fail as forbidden`() { convenienceClient.prepareDatabaseWithProcessedData( @@ -182,4 +199,69 @@ class ApproveProcessedDataEndpointTest( convenienceClient.getSequenceEntryOfUser(accession = "11", version = 1, organism = OTHER_ORGANISM) .assertStatusIs(AWAITING_APPROVAL) } + + @Test + fun `GIVEN data with warnings WHEN I approve with different scopes THEN data are approved depending on scope`() { + val submittedSequences = + convenienceClient.prepareDefaultSequenceEntriesToInProcessing() + val accessionOfSuccessfullyProcessedData = submittedSequences[0].accession + val accessionOfDataWithWarnings = submittedSequences[1].accession + + convenienceClient.submitProcessedData( + PreparedProcessedData.withWarnings(accession = accessionOfDataWithWarnings), + ) + convenienceClient.submitProcessedData( + PreparedProcessedData.successfullyProcessed(accession = accessionOfSuccessfullyProcessedData), + ) + + client.approveProcessedSequenceEntries(scope = ApproveDataScope.WITHOUT_WARNINGS) + .andExpect(status().isNoContent) + + convenienceClient.getSequenceEntryOfUser(accession = accessionOfDataWithWarnings, version = 1) + .assertStatusIs(AWAITING_APPROVAL) + convenienceClient.getSequenceEntryOfUser(accession = accessionOfSuccessfullyProcessedData, version = 1) + .assertStatusIs(APPROVED_FOR_RELEASE) + + client.approveProcessedSequenceEntries(scope = ApproveDataScope.ALL) + .andExpect(status().isNoContent) + + convenienceClient.getSequenceEntryOfUser(accession = accessionOfDataWithWarnings, version = 1) + .assertStatusIs(APPROVED_FOR_RELEASE) + } + + @Test + @Suppress("ktlint:standard:max-line-length") + fun `GIVEN data with and without warnings WHEN I approve with warnings excluded THEN only sequence without warning is approved`() { + val submittedSequences = + convenienceClient.prepareDefaultSequenceEntriesToInProcessing() + val accessionOfSuccessfullyProcessedData = submittedSequences[0].accession + val accessionOfDataWithWarnings = submittedSequences[1].accession + val accessionOfAnotherSuccessfullyProcessedData = submittedSequences[2].accession + + convenienceClient.submitProcessedData( + PreparedProcessedData.withWarnings(accession = accessionOfDataWithWarnings), + ) + convenienceClient.submitProcessedData( + PreparedProcessedData.successfullyProcessed(accession = accessionOfSuccessfullyProcessedData), + ) + convenienceClient.submitProcessedData( + PreparedProcessedData.successfullyProcessed(accession = accessionOfAnotherSuccessfullyProcessedData), + ) + + client.approveProcessedSequenceEntries( + scope = ApproveDataScope.WITHOUT_WARNINGS, + listOfSequencesToApprove = listOf( + AccessionVersion(accessionOfDataWithWarnings, 1), + AccessionVersion(accessionOfSuccessfullyProcessedData, 1), + ), + ) + .andExpect(status().isNoContent) + + convenienceClient.getSequenceEntryOfUser(accession = accessionOfDataWithWarnings, version = 1) + .assertStatusIs(AWAITING_APPROVAL) + convenienceClient.getSequenceEntryOfUser(accession = accessionOfSuccessfullyProcessedData, version = 1) + .assertStatusIs(APPROVED_FOR_RELEASE) + convenienceClient.getSequenceEntryOfUser(accession = accessionOfAnotherSuccessfullyProcessedData, version = 1) + .assertStatusIs(AWAITING_APPROVAL) + } } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/DeleteSequencesEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/DeleteSequencesEndpointTest.kt index 6e14e0c84e..918a070674 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/DeleteSequencesEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/DeleteSequencesEndpointTest.kt @@ -1,18 +1,27 @@ package org.loculus.backend.controller.submission import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.CoreMatchers.hasItem import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.not import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.hamcrest.Matchers.hasProperty +import org.hamcrest.Matchers.hasSize import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.loculus.backend.api.AccessionVersion +import org.loculus.backend.api.DeleteSequenceScope +import org.loculus.backend.api.SequenceEntryStatus import org.loculus.backend.api.Status import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.EndpointTest import org.loculus.backend.controller.OTHER_ORGANISM +import org.loculus.backend.controller.assertStatusIs import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.generateJwtFor +import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES import org.loculus.backend.controller.toAccessionVersion import org.loculus.backend.utils.AccessionVersionComparator import org.springframework.beans.factory.annotation.Autowired @@ -31,7 +40,7 @@ class DeleteSequencesEndpointTest( fun `GIVEN invalid authorization token THEN returns 401 Unauthorized`() { expectUnauthorizedResponse(isModifyingRequest = true) { client.deleteSequenceEntries( - emptyList(), + listOfAccessionVersionsToDelete = emptyList(), jwt = it, ) } @@ -49,10 +58,17 @@ class DeleteSequencesEndpointTest( ) val deletionResult = client.deleteSequenceEntries( - accessionVersionsToDelete.map { AccessionVersion(it.accession, it.version) }, + listOfAccessionVersionsToDelete = accessionVersionsToDelete.map { + AccessionVersion(it.accession, it.version) + }, ) - deletionResult.andExpect(status().isNoContent) + deletionResult + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$.length()").value(accessionVersionsToDelete.size)) + .andExpect(jsonPath("\$[*].accession").value(accessionVersionsToDelete.map { it.accession })) + assertThat( convenienceClient.getSequenceEntriesOfUserInState( status = testScenario.statusAfterPreparation, @@ -73,7 +89,9 @@ class DeleteSequencesEndpointTest( ) val deletionResult = client.deleteSequenceEntries( - accessionVersionsToDelete.map { AccessionVersion(it.accession, it.version) }, + listOfAccessionVersionsToDelete = accessionVersionsToDelete.map { + AccessionVersion(it.accession, it.version) + }, ) val listOfAllowedStatuses = "[${Status.RECEIVED}, ${Status.AWAITING_APPROVAL}, " + @@ -93,7 +111,7 @@ class DeleteSequencesEndpointTest( convenienceClient.getSequenceEntriesOfUserInState( status = testScenario.statusAfterPreparation, ).size, - `is`(SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES), + `is`(NUMBER_OF_SEQUENCES), ) } @@ -104,19 +122,94 @@ class DeleteSequencesEndpointTest( val nonExistingAccession = AccessionVersion("123", 1) val nonExistingVersion = AccessionVersion("1", 123) - client.deleteSequenceEntries(listOf(nonExistingAccession, nonExistingVersion)) + client.deleteSequenceEntries(listOfAccessionVersionsToDelete = listOf(nonExistingAccession, nonExistingVersion)) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( - jsonPath("\$.detail", containsString("Accession versions 1.123, 123.1 do not exist")), + jsonPath( + "\$.detail", + containsString("Accession versions 1.123, 123.1 do not exist"), + ), ) } + @Test + fun `WHEN deleting via scope = ALL THEN expect all accessions to be deleted `() { + val erroneousSequences = convenienceClient.prepareDataTo(Status.HAS_ERRORS) + val approvableSequences = convenienceClient.prepareDataTo(Status.AWAITING_APPROVAL) + + assertThat( + convenienceClient.getSequenceEntries().sequenceEntries, + Matchers.hasSize(erroneousSequences.size + approvableSequences.size), + ) + + client.deleteSequenceEntries(scope = DeleteSequenceScope.ALL) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.length()").value(2 * NUMBER_OF_SEQUENCES)) + + assertThat( + convenienceClient.getSequenceEntries().sequenceEntries, + hasSize(0), + ) + } + + @Test + fun `WHEN deleting via scope = PROCESSED_WITH_ERRORS THEN expect all accessions with errors to be deleted `() { + val erroneousSequences = convenienceClient.prepareDataTo(Status.HAS_ERRORS) + val approvableSequences = convenienceClient.prepareDataTo(Status.AWAITING_APPROVAL) + + convenienceClient.expectStatusCountsOfSequenceEntries( + mapOf( + Status.HAS_ERRORS to erroneousSequences.size, + Status.AWAITING_APPROVAL to approvableSequences.size, + ), + ) + + client.deleteSequenceEntries(scope = DeleteSequenceScope.PROCESSED_WITH_ERRORS) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.length()").value(NUMBER_OF_SEQUENCES)) + + convenienceClient.expectStatusCountsOfSequenceEntries( + mapOf( + Status.HAS_ERRORS to 0, + Status.AWAITING_APPROVAL to approvableSequences.size, + ), + ) + } + + @Test + fun `WHEN deleting via scope = PROCESSED_WITH_WARNINGS THEN expect all accessions with warnings to be deleted `() { + val originalSubmission = convenienceClient.prepareDefaultSequenceEntriesToInProcessing() + val sequenceWithWarning = PreparedProcessedData.withWarnings() + convenienceClient.submitProcessedData(sequenceWithWarning) + + val countOfSequenceEntriesWithWarnings = 1 + + assertThat( + convenienceClient.getSequenceEntries().sequenceEntries, + hasSize(originalSubmission.size), + ) + + val containsSequenceWithWarning = + hasItem(hasProperty("accession", `is`(sequenceWithWarning.accession))) + + assertThat(convenienceClient.getSequenceEntries().sequenceEntries, containsSequenceWithWarning) + + client.deleteSequenceEntries(scope = DeleteSequenceScope.PROCESSED_WITH_WARNINGS) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.length()").value(countOfSequenceEntriesWithWarnings)) + + assertThat(convenienceClient.getSequenceEntries().sequenceEntries, not(containsSequenceWithWarning)) + } + @Test fun `WHEN deleting sequence entry of wrong organism THEN throws an unprocessableEntity error`() { val accessionVersion = convenienceClient.submitDefaultFiles(organism = DEFAULT_ORGANISM)[0] - client.deleteSequenceEntries(listOf(accessionVersion.toAccessionVersion()), organism = OTHER_ORGANISM) + client.deleteSequenceEntries( + listOfAccessionVersionsToDelete = listOf(accessionVersion.toAccessionVersion()), + organism = OTHER_ORGANISM, + ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( @@ -130,7 +223,7 @@ class DeleteSequencesEndpointTest( val notSubmitter = "theOneWhoMustNotBeNamed" client.deleteSequenceEntries( - listOf( + listOfAccessionVersionsToDelete = listOf( AccessionVersion("1", 1), AccessionVersion("2", 1), ), @@ -143,6 +236,40 @@ class DeleteSequencesEndpointTest( ) } + @Test + @Suppress("ktlint:standard:max-line-length") + fun `GIVEN data with and without warnings WHEN I delete only warnings THEN only sequence with warning is deleted`() { + val submittedSequences = + convenienceClient.prepareDefaultSequenceEntriesToInProcessing() + val accessionOfSuccessfullyProcessedData = submittedSequences[0].accession + val accessionWithWarnings = submittedSequences[1].accession + + convenienceClient.submitProcessedData( + PreparedProcessedData.withWarnings(accession = accessionWithWarnings), + ) + convenienceClient.submitProcessedData( + PreparedProcessedData.successfullyProcessed(accession = accessionOfSuccessfullyProcessedData), + ) + + client.deleteSequenceEntries( + scope = DeleteSequenceScope.PROCESSED_WITH_WARNINGS, + listOfAccessionVersionsToDelete = listOf( + AccessionVersion(accessionWithWarnings, 1), + AccessionVersion(accessionOfSuccessfullyProcessedData, 1), + ), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.length()").value(1)) + + assertThat( + convenienceClient.getSequenceEntries().sequenceEntries, + not(hasItem(hasProperty("accession", `is`(accessionWithWarnings)))), + ) + + convenienceClient.getSequenceEntryOfUser(accession = accessionOfSuccessfullyProcessedData, version = 1) + .assertStatusIs(Status.AWAITING_APPROVAL) + } + companion object { @JvmStatic fun provideValidTestScenarios() = listOf( diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt index e487f2d3fa..fe87eef4bc 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt @@ -17,6 +17,7 @@ import org.loculus.backend.api.Status.AWAITING_APPROVAL_FOR_REVOCATION import org.loculus.backend.api.Status.HAS_ERRORS import org.loculus.backend.api.Status.IN_PROCESSING import org.loculus.backend.api.Status.RECEIVED +import org.loculus.backend.api.WarningsFilter import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_GROUP_NAME import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_USER_NAME import org.loculus.backend.controller.DEFAULT_GROUP_NAME @@ -140,6 +141,20 @@ class GetSequencesEndpointTest( assertThat(sequencesInProcessing, hasSize(0)) } + @Test + fun `GIVEN data with warnings WHEN I exclude warnings THEN expect no data returned`() { + convenienceClient.prepareDefaultSequenceEntriesToInProcessing() + convenienceClient.submitProcessedData(PreparedProcessedData.withWarnings()) + + val sequencesInAwaitingApproval = convenienceClient.getSequenceEntries( + username = ALTERNATIVE_DEFAULT_USER_NAME, + statusesFilter = listOf(AWAITING_APPROVAL), + warningsFilter = WarningsFilter.EXCLUDE_WARNINGS, + ).sequenceEntries + + assertThat(sequencesInAwaitingApproval, hasSize(0)) + } + @Test fun `GIVEN data in many statuses WHEN querying sequences with pagination THEN return paged results`() { val allSubmittedSequencesSorted = convenienceClient.prepareDataTo(AWAITING_APPROVAL).map { @@ -220,7 +235,7 @@ class GetSequencesEndpointTest( Scenario( setupDescription = "I submitted sequence entries that have errors", prepareDatabase = { it.prepareDefaultSequenceEntriesToHasErrors() }, - expectedStatus = Status.HAS_ERRORS, + expectedStatus = HAS_ERRORS, expectedIsRevocation = false, ), Scenario( diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt index f0e85c04d0..71048b2a5d 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt @@ -2,10 +2,13 @@ package org.loculus.backend.controller.submission import com.fasterxml.jackson.databind.ObjectMapper import org.loculus.backend.api.AccessionVersion +import org.loculus.backend.api.ApproveDataScope import org.loculus.backend.api.DataUseTerms +import org.loculus.backend.api.DeleteSequenceScope import org.loculus.backend.api.Status import org.loculus.backend.api.SubmittedProcessedData import org.loculus.backend.api.UnprocessedData +import org.loculus.backend.api.WarningsFilter import org.loculus.backend.controller.DEFAULT_GROUP_NAME import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.addOrganismToPath @@ -83,6 +86,7 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec organism: String = DEFAULT_ORGANISM, groupsFilter: List? = null, statusesFilter: List? = null, + warningsFilter: WarningsFilter? = null, jwt: String? = jwtForDefaultUser, page: Int? = null, size: Int? = null, @@ -92,6 +96,7 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec .withAuth(jwt) .param("groupsFilter", groupsFilter?.joinToString { it }) .param("statusesFilter", statusesFilter?.joinToString { it.name }) + .param("warningsFilter", warningsFilter?.name) .param("page", page?.toString()) .param("size", size?.toString()), ) @@ -135,13 +140,18 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec } fun approveProcessedSequenceEntries( - listOfSequencesToApprove: List, + listOfSequencesToApprove: List? = null, organism: String = DEFAULT_ORGANISM, + scope: ApproveDataScope = ApproveDataScope.ALL, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( post(addOrganismToPath("/approve-processed-data", organism = organism)) .contentType(MediaType.APPLICATION_JSON) - .content("""{"accessionVersions":${objectMapper.writeValueAsString(listOfSequencesToApprove)}}""") + .content( + """{"accessionVersionsFilter": ${createAccessionVersionsFilterBodyString(listOfSequencesToApprove)}, + "scope": "$scope" + }""", + ) .withAuth(jwt), ) @@ -174,7 +184,8 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec ) fun deleteSequenceEntries( - listOfAccessionVersionsToDelete: List, + listOfAccessionVersionsToDelete: List? = null, + scope: DeleteSequenceScope = DeleteSequenceScope.ALL, organism: String = DEFAULT_ORGANISM, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( @@ -182,7 +193,11 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec .withAuth(jwt) .contentType(MediaType.APPLICATION_JSON) .content( - """{"accessionVersions":${objectMapper.writeValueAsString(listOfAccessionVersionsToDelete)}}""", + """{"accessionVersionsFilter":${createAccessionVersionsFilterBodyString( + listOfAccessionVersionsToDelete, + )}, + "scope": "$scope"} + """.trimMargin(), ), ) @@ -197,4 +212,16 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec .file(metadataFile) .withAuth(jwt), ) + + private fun createAccessionVersionsFilterBodyString( + listOfSequencesToApprove: List? = null, + ): String { + return if (listOfSequencesToApprove != null) { + objectMapper.writeValueAsString( + listOfSequencesToApprove, + ) + } else { + "null" + } + } } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt index 3171d59c23..f9ddccfa38 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt @@ -2,6 +2,8 @@ package org.loculus.backend.controller.submission import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.AccessionVersionInterface import org.loculus.backend.api.DataUseTerms @@ -14,6 +16,7 @@ import org.loculus.backend.api.Status import org.loculus.backend.api.SubmissionIdMapping import org.loculus.backend.api.SubmittedProcessedData import org.loculus.backend.api.UnprocessedData +import org.loculus.backend.api.WarningsFilter import org.loculus.backend.config.BackendConfig import org.loculus.backend.controller.DEFAULT_GROUP_NAME import org.loculus.backend.controller.DEFAULT_ORGANISM @@ -165,6 +168,7 @@ class SubmissionConvenienceClient( groupsFilter: List? = null, statusesFilter: List? = null, organism: String = DEFAULT_ORGANISM, + warningsFilter: WarningsFilter = WarningsFilter.INCLUDE_WARNINGS, page: Int? = null, size: Int? = null, ): GetSequenceResponse = deserializeJsonResponse( @@ -172,6 +176,7 @@ class SubmissionConvenienceClient( organism = organism, groupsFilter = groupsFilter, statusesFilter = statusesFilter, + warningsFilter = warningsFilter, jwt = generateJwtFor(username), page = page, size = size, @@ -218,6 +223,21 @@ class SubmissionConvenienceClient( ), ) + fun expectStatusCountsOfSequenceEntries(statusCounts: Map, userName: String = DEFAULT_USER_NAME) { + val actualStatusCounts = deserializeJsonResponse( + client.getSequenceEntries(jwt = generateJwtFor(userName)) + .andExpect(status().isOk) + .andExpect( + content().contentType(MediaType.APPLICATION_JSON_VALUE), + ), + ).statusCounts + + assertThat( + actualStatusCounts, + equalTo(Status.entries.associateWith { 0 } + statusCounts), + ) + } + fun submitDefaultEditedData(userName: String = DEFAULT_USER_NAME) { DefaultFiles.allAccessions.forEach { accession -> client.submitEditedSequenceEntryVersion( diff --git a/website/playwright.config.ts b/website/playwright.config.ts index c4ef3cc505..1c1da480fc 100644 --- a/website/playwright.config.ts +++ b/website/playwright.config.ts @@ -6,7 +6,9 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', - fullyParallel: true, + // This option allows parallel execution of tests in a single file. + // It is disabled by default because it can cause issues with some tests. + fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 1, workers: process.env.CI ? 1 : undefined, diff --git a/website/src/components/ReviewPage/ReviewPage.tsx b/website/src/components/ReviewPage/ReviewPage.tsx index 4308144701..303de3b9b4 100644 --- a/website/src/components/ReviewPage/ReviewPage.tsx +++ b/website/src/components/ReviewPage/ReviewPage.tsx @@ -5,7 +5,10 @@ import { ReviewCard } from './ReviewCard.tsx'; import { useSubmissionOperations } from '../../hooks/useSubmissionOperations.ts'; import { routes } from '../../routes.ts'; import { + approveAllDataScope, awaitingApprovalStatus, + deleteAllDataScope, + deleteProcessedDataWithErrorsScope, hasErrorsStatus, inProcessingStatus, type PageQuery, @@ -107,7 +110,7 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo className='border rounded-md p-1 bg-gray-500 text-white px-2' onClick={() => { hooks.deleteSequenceEntries({ - accessionVersions: sequences.filter((sequence) => sequence.status === hasErrorsStatus), + scope: deleteProcessedDataWithErrorsScope.value, }); }} > @@ -119,9 +122,7 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo className='border rounded-md p-1 bg-gray-500 text-white px-2 ml-2' onClick={() => hooks.approveProcessedData({ - accessionVersions: sequences.filter( - (sequence) => sequence.status === awaitingApprovalStatus, - ), + scope: approveAllDataScope.value, }) } > @@ -143,12 +144,14 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo sequenceEntryStatus={sequence} approveAccessionVersion={() => hooks.approveProcessedData({ - accessionVersions: [sequence], + accessionVersionsFilter: [sequence], + scope: approveAllDataScope.value, }) } deleteAccessionVersion={() => hooks.deleteSequenceEntries({ - accessionVersions: [sequence], + accessionVersionsFilter: [sequence], + scope: deleteAllDataScope.value, }) } editAccessionVersion={() => { diff --git a/website/src/components/UserSequenceList/sequenceActions.ts b/website/src/components/UserSequenceList/sequenceActions.ts index e469e020f6..a05b53260d 100644 --- a/website/src/components/UserSequenceList/sequenceActions.ts +++ b/website/src/components/UserSequenceList/sequenceActions.ts @@ -1,6 +1,11 @@ import type { ActionHooks } from './SequenceEntryTable.tsx'; import { routes } from '../../routes.ts'; -import type { AccessionVersion, SequenceEntryStatus } from '../../types/backend.ts'; +import { + type AccessionVersion, + type SequenceEntryStatus, + approveAllDataScope, + deleteAllDataScope, +} from '../../types/backend.ts'; import { extractAccessionVersion, getAccessionVersionString } from '../../utils/extractAccessionVersion.ts'; export type BulkSequenceAction = { @@ -14,7 +19,10 @@ export type BulkSequenceAction = { const deleteAction: BulkSequenceAction = { name: 'delete', actionOnSequenceEntries: async (selectedSequences, actionHooks) => - actionHooks.deleteSequenceEntries({ accessionVersions: selectedSequences.map(extractAccessionVersion) }), + actionHooks.deleteSequenceEntries({ + accessionVersionsFilter: selectedSequences.map(extractAccessionVersion), + scope: deleteAllDataScope.value, + }), confirmationDialog: { message: (selectedSequences) => `Are you sure you want to delete the selected sequence entry ${pluralizeWord( @@ -27,7 +35,10 @@ const deleteAction: BulkSequenceAction = { const approveAction: BulkSequenceAction = { name: 'approve', actionOnSequenceEntries: async (selectedSequences, actionHooks) => - actionHooks.approveProcessedData({ accessionVersions: selectedSequences.map(extractAccessionVersion) }), + actionHooks.approveProcessedData({ + accessionVersionsFilter: selectedSequences.map(extractAccessionVersion), + scope: approveAllDataScope.value, + }), confirmationDialog: { message: (selectedSequences) => `Are you sure you want to approve the selected sequence entry ${pluralizeWord( diff --git a/website/src/services/backendApi.ts b/website/src/services/backendApi.ts index ec3dfbc2c0..c5dcc33560 100644 --- a/website/src/services/backendApi.ts +++ b/website/src/services/backendApi.ts @@ -4,6 +4,9 @@ import z from 'zod'; import { authorizationHeader, notAuthorizedError, withOrganismPathSegment } from './commonApiTypes.ts'; import { accessions, + accessionVersion, + accessionVersionsFilterWithApprovalScope, + accessionVersionsFilterWithDeletionScope, accessionVersionsObject, dataUseTermsHistoryEntry, getSequencesResponse, @@ -140,7 +143,7 @@ const approveProcessedDataEndpoint = makeEndpoint({ { name: 'data', type: 'Body', - schema: accessionVersionsObject, + schema: accessionVersionsFilterWithApprovalScope, }, ], response: z.never(), @@ -156,10 +159,10 @@ const deleteSequencesEndpoint = makeEndpoint({ { name: 'accessionVersions', type: 'Body', - schema: accessionVersionsObject, + schema: accessionVersionsFilterWithDeletionScope, }, ], - response: z.never(), + response: z.array(accessionVersion), errors: [{ status: 'default', schema: problemDetail }, notAuthorizedError], }); diff --git a/website/src/types/backend.ts b/website/src/types/backend.ts index a4caab20cc..c7e9a1f043 100644 --- a/website/src/types/backend.ts +++ b/website/src/types/backend.ts @@ -58,6 +58,35 @@ export const accessionVersionsObject = z.object({ accessionVersions: z.array(accessionVersion), }); +export const accessionVersionsFilter = z.object({ + accessionVersionsFilter: z.array(accessionVersion).optional(), +}); + +export const approveAllDataScope = z.literal('ALL'); +export const approveProcessedDataWithoutWarningsScope = z.literal('WITHOUT_WARNINGS'); + +export const accessionVersionsFilterWithApprovalScope = accessionVersionsFilter.merge( + z.object({ + scope: z.union([approveAllDataScope, approveProcessedDataWithoutWarningsScope]), + }), +); + +export const deleteAllDataScope = z.literal('ALL'); +export const deleteProcessedAndRevocationConfirmationDataScope = z.literal('ALL_PROCESSED_AND_REVOCATIONS'); +export const deleteProcessedDataWithErrorsScope = z.literal('PROCESSED_WITH_ERRORS'); +export const deleteProcessedDataWithWarningsScope = z.literal('PROCESSED_WITH_WARNINGS'); + +export const accessionVersionsFilterWithDeletionScope = accessionVersionsFilter.merge( + z.object({ + scope: z.union([ + deleteAllDataScope, + deleteProcessedAndRevocationConfirmationDataScope, + deleteProcessedDataWithErrorsScope, + deleteProcessedDataWithWarningsScope, + ]), + }), +); + export const openDataUseTermsType = 'OPEN'; export const restrictedDataUseTermsType = 'RESTRICTED'; diff --git a/website/src/utils/stringifyMaybeAxiosError.ts b/website/src/utils/stringifyMaybeAxiosError.ts index 792d73062b..b573296e51 100644 --- a/website/src/utils/stringifyMaybeAxiosError.ts +++ b/website/src/utils/stringifyMaybeAxiosError.ts @@ -8,5 +8,5 @@ export const stringifyMaybeAxiosError = (error: unknown): string => { return (data as ProblemDetail).detail; } - return error?.toString() ?? JSON.stringify((error as Error).message); + return JSON.stringify((error as Error).message); }; diff --git a/website/tests/e2e.fixture.ts b/website/tests/e2e.fixture.ts index 258ab97546..91b30a6b78 100644 --- a/website/tests/e2e.fixture.ts +++ b/website/tests/e2e.fixture.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'fs'; import { type Page, test as base } from '@playwright/test'; +import { isErrorFromAlias } from '@zodios/core'; import { ResultAsync } from 'neverthrow'; import { Issuer } from 'openid-client'; import winston from 'winston'; @@ -8,14 +9,17 @@ import winston from 'winston'; import { DatasetPage } from './pages/datasets/dataset.page'; import { EditPage } from './pages/edit/edit.page'; import { NavigationFixture } from './pages/navigation.fixture'; +import { ReviewPage } from './pages/review/review.page.ts'; import { RevisePage } from './pages/revise/revise.page'; import { SearchPage } from './pages/search/search.page'; import { SequencePage } from './pages/sequences/sequences.page'; import { SubmitPage } from './pages/submit/submit.page'; import { GroupPage } from './pages/user/group/group.page.ts'; import { UserSequencePage } from './pages/user/userSequencePage/userSequencePage.ts'; +import { createGroup } from './util/backendCalls.ts'; import { ACCESS_TOKEN_COOKIE, clientMetadata, realmPath, REFRESH_TOKEN_COOKIE } from '../src/middleware/authMiddleware'; import { BackendClient } from '../src/services/backendClient'; +import { groupManagementApi } from '../src/services/groupManagementApi.ts'; import { GroupManagementClient } from '../src/services/groupManagementClient.ts'; import { type DataUseTerms, openDataUseTermsType } from '../src/types/backend.ts'; @@ -23,13 +27,14 @@ type E2EFixture = { searchPage: SearchPage; sequencePage: SequencePage; submitPage: SubmitPage; + reviewPage: ReviewPage; datasetPage: DatasetPage; userPage: UserSequencePage; groupPage: GroupPage; revisePage: RevisePage; editPage: EditPage; navigationFixture: NavigationFixture; - loginAsTestUser: () => Promise<{ username: string; token: string }>; + loginAsTestUser: () => Promise<{ username: string; token: string; groupName: string }>; }; export const dummyOrganism = { key: 'dummy-organism', displayName: 'Test Dummy Organism' }; @@ -42,6 +47,8 @@ export const backendUrl = 'http://localhost:8079'; export const lapisUrl = 'http://localhost:8080/dummy-organism'; const keycloakUrl = 'http://localhost:8083'; +export const DEFAULT_GROUP_NAME = 'testGroup'; + export const e2eLogger = winston.createLogger({ level: 'info', format: winston.format.combine(winston.format.timestamp(), winston.format.json()), @@ -136,9 +143,12 @@ export async function authorize( ) { const username = `${testUser}_${parallelIndex}_${browser?.browserType().name()}`; const password = `${testUserPassword}_${parallelIndex}_${browser?.browserType().name()}`; + const groupName = username + '-group'; const token = await getToken(username, password); + await createTestGroupIfNotExistent(token.accessToken, groupName); + await page.context().addCookies([ { name: ACCESS_TOKEN_COOKIE, @@ -162,6 +172,7 @@ export async function authorize( return { username, + groupName, token: token.accessToken, }; } @@ -179,6 +190,10 @@ export const test = base.extend({ const submitPage = new SubmitPage(page); await use(submitPage); }, + reviewPage: async ({ page }, use) => { + const reviewPage = new ReviewPage(page); + await use(reviewPage); + }, userPage: async ({ page }, use) => { const userPage = new UserSequencePage(page); await use(userPage); @@ -207,4 +222,16 @@ export const test = base.extend({ }, }); +export async function createTestGroupIfNotExistent(token: string, groupName: string = DEFAULT_GROUP_NAME) { + try { + await createGroup(groupName, token); + } catch (error) { + const groupDoesAlreadyExist = + isErrorFromAlias(groupManagementApi, 'createGroup', error) && error.response.status === 409; + if (!groupDoesAlreadyExist) { + throw error; + } + } +} + export { expect } from '@playwright/test'; diff --git a/website/tests/pages/edit/index.spec.ts b/website/tests/pages/edit/index.spec.ts index 759f497264..4dad36de74 100644 --- a/website/tests/pages/edit/index.spec.ts +++ b/website/tests/pages/edit/index.spec.ts @@ -10,10 +10,10 @@ test.describe('The edit page', () => { 'should show the edit page for a sequence entry that has errors, ' + 'download the sequence and submit the edited data', async ({ userPage, editPage, loginAsTestUser }) => { - const { token } = await loginAsTestUser(); + const { token, groupName } = await loginAsTestUser(); - const [erroneousTestSequenceEntry] = await prepareDataToBe('erroneous', token, 1); - const [stagedTestSequenceEntry] = await prepareDataToBe('awaitingApproval', token, 1); + const [erroneousTestSequenceEntry] = await prepareDataToBe('erroneous', token, 1, groupName); + const [stagedTestSequenceEntry] = await prepareDataToBe('awaitingApproval', token, 1, groupName); expect(erroneousTestSequenceEntry).toBeDefined(); expect(stagedTestSequenceEntry).toBeDefined(); diff --git a/website/tests/pages/review/index.spec.ts b/website/tests/pages/review/index.spec.ts new file mode 100644 index 0000000000..a527fa21a9 --- /dev/null +++ b/website/tests/pages/review/index.spec.ts @@ -0,0 +1,54 @@ +import { test, testSequenceCount } from '../../e2e.fixture'; +import { submitViaApi } from '../../util/backendCalls.ts'; +import { prepareDataToBe } from '../../util/prepareDataToBe.ts'; + +test.describe('The review page', () => { + test('should show the total sequences and an increase when new submission occurs', async ({ + reviewPage, + loginAsTestUser, + }) => { + const { token, groupName } = await loginAsTestUser(); + + await reviewPage.goto(); + + const { total } = await reviewPage.getReviewPageOverview(); + + await submitViaApi(testSequenceCount, token, groupName); + + await reviewPage.waitForTotalSequencesFulfillPredicate( + (totalSequenceCount) => totalSequenceCount === total + testSequenceCount, + ); + }); + + test('should allow bulk approval', async ({ reviewPage, loginAsTestUser }) => { + const { token, groupName } = await loginAsTestUser(); + + await reviewPage.goto(); + + const { total } = await reviewPage.getReviewPageOverview(); + + await prepareDataToBe('awaitingApproval', token, testSequenceCount, groupName); + + await reviewPage.waitForTotalSequencesFulfillPredicate( + (totalSequenceCount) => totalSequenceCount === total + testSequenceCount, + ); + + await reviewPage.approveAll(); + + await reviewPage.waitForTotalSequencesFulfillPredicate((totalSequenceCount) => totalSequenceCount === total); + }); + + test('should allow bulk deletion', async ({ reviewPage, loginAsTestUser }) => { + const { token, groupName } = await loginAsTestUser(); + + await prepareDataToBe('erroneous', token, testSequenceCount, groupName); + + await reviewPage.goto(); + + const { total } = await reviewPage.getReviewPageOverview(); + + await reviewPage.deleteAll(); + + await reviewPage.waitForTotalSequencesFulfillPredicate((totalSequenceCount) => totalSequenceCount < total); + }); +}); diff --git a/website/tests/pages/review/review.page.ts b/website/tests/pages/review/review.page.ts new file mode 100644 index 0000000000..2636dce216 --- /dev/null +++ b/website/tests/pages/review/review.page.ts @@ -0,0 +1,73 @@ +import type { Locator, Page } from '@playwright/test'; + +import { routes } from '../../../src/routes.ts'; +import { baseUrl, dummyOrganism, expect } from '../../e2e.fixture'; + +type ReviewPageOverview = { + processed: number; + total: number; +}; + +export class ReviewPage { + public readonly approveAllButton: Locator; + public readonly deleteAllButton: Locator; + + constructor(public readonly page: Page) { + this.approveAllButton = page.getByRole('button', { name: 'Release', exact: false }); + this.deleteAllButton = page.getByRole('button', { name: 'Discard', exact: false }); + } + + public async goto() { + await this.page.goto(`${baseUrl}${routes.userSequenceReviewPage(dummyOrganism.key)}`, { + waitUntil: 'networkidle', + }); + } + + public async getReviewPageOverview(): Promise { + if (await this.page.getByText('No sequences to review', { exact: false }).isVisible()) { + return { processed: 0, total: 0 }; + } + + await this.page.waitForSelector(':text("sequences processed.")'); + + const infoText = await this.page.$eval(':text("sequences processed.")', (element) => element.textContent); + + const matchResult = infoText?.match(/(\d+) of (\d+) sequences processed/) ?? null; + + if (matchResult !== null) { + const processed = parseInt(matchResult[1], 10); + const total = parseInt(matchResult[2], 10); + + return { processed, total }; + } else { + throw new Error('Unable to extract processed sequences information from the page.'); + } + } + + public async waitForTotalSequencesFulfillPredicate( + predicate: (totalSequenceCount: number) => boolean, + retries: number = 10, + delayInSeconds: number = 1, + ) { + for (let i = 0; i < retries; i++) { + await new Promise((resolve) => setTimeout(resolve, delayInSeconds * 1000)); + + const { total: currentTotal } = await this.getReviewPageOverview(); + if (predicate(currentTotal)) { + return true; + } + } + + throw new Error(`Waiting for total count of sequences to match predicate, but total did not match predicate`); + } + + public async approveAll() { + await expect(this.approveAllButton).toBeVisible(); + await this.approveAllButton.click(); + } + + public async deleteAll() { + await expect(this.deleteAllButton).toBeVisible(); + await this.deleteAllButton.click(); + } +} diff --git a/website/tests/pages/revise/index.spec.ts b/website/tests/pages/revise/index.spec.ts index 434b626c7b..c421bd70e7 100644 --- a/website/tests/pages/revise/index.spec.ts +++ b/website/tests/pages/revise/index.spec.ts @@ -1,12 +1,12 @@ import { routes } from '../../../src/routes.ts'; -import { baseUrl, dummyOrganism, test } from '../../e2e.fixture'; +import { baseUrl, dummyOrganism, test, testSequenceCount } from '../../e2e.fixture'; import { prepareDataToBe } from '../../util/prepareDataToBe.ts'; test.describe('The revise page', () => { test('should upload files and revise existing data', async ({ revisePage, loginAsTestUser }) => { - const { token } = await loginAsTestUser(); + const { token, groupName } = await loginAsTestUser(); - const sequenceEntries = await prepareDataToBe('approvedForRelease', token); + const sequenceEntries = await prepareDataToBe('approvedForRelease', token, testSequenceCount, groupName); await revisePage.goto(); diff --git a/website/tests/pages/user/index.spec.ts b/website/tests/pages/user/index.spec.ts index bcfb055c16..2ebb12f610 100644 --- a/website/tests/pages/user/index.spec.ts +++ b/website/tests/pages/user/index.spec.ts @@ -1,17 +1,16 @@ import { v4 } from 'uuid'; import { expect, test } from '../../e2e.fixture'; -import { DEFAULT_GROUP_NAME } from '../../playwrightSetup.ts'; test.describe('The user page', () => { test('should see the groups the user is member of, create a group and leave it afterwards', async ({ groupPage, loginAsTestUser, }) => { - await loginAsTestUser(); + const { groupName } = await loginAsTestUser(); await groupPage.goToUserPage(); - await groupPage.verifyGroupIsPresent(DEFAULT_GROUP_NAME); + await groupPage.verifyGroupIsPresent(groupName); const uniqueGroupName = v4(); await groupPage.createGroup(uniqueGroupName); diff --git a/website/tests/pages/user/userSequencePage/index.spec.ts b/website/tests/pages/user/userSequencePage/index.spec.ts index fd124d8fae..82b126faf7 100644 --- a/website/tests/pages/user/userSequencePage/index.spec.ts +++ b/website/tests/pages/user/userSequencePage/index.spec.ts @@ -1,4 +1,4 @@ -import { expect, openDataUseTerms, test } from '../../../e2e.fixture'; +import { expect, openDataUseTerms, test, testSequenceCount } from '../../../e2e.fixture'; import { submitRevisedDataViaApi } from '../../../util/backendCalls.ts'; import { prepareDataToBe } from '../../../util/prepareDataToBe.ts'; @@ -7,12 +7,27 @@ test.describe('The user sequence page', () => { userPage, loginAsTestUser, }) => { - const { token } = await loginAsTestUser(); + const { token, groupName } = await loginAsTestUser(); - const [sequenceEntryAwaitingApproval] = await prepareDataToBe('awaitingApproval', token); - const [sequenceEntryWithErrors] = await prepareDataToBe('erroneous', token); - const [sequenceEntryReleasable] = await prepareDataToBe('approvedForRelease', token); - const [sequenceEntryToBeRevised] = await prepareDataToBe('approvedForRelease', token); + const [sequenceEntryAwaitingApproval] = await prepareDataToBe( + 'awaitingApproval', + token, + testSequenceCount, + groupName, + ); + const [sequenceEntryWithErrors] = await prepareDataToBe('erroneous', token, testSequenceCount, groupName); + const [sequenceEntryReleasable] = await prepareDataToBe( + 'approvedForRelease', + token, + testSequenceCount, + groupName, + ); + const [sequenceEntryToBeRevised] = await prepareDataToBe( + 'approvedForRelease', + token, + testSequenceCount, + groupName, + ); await submitRevisedDataViaApi([sequenceEntryToBeRevised.accession], token); await userPage.gotoUserSequencePage(); diff --git a/website/tests/playwrightSetup.ts b/website/tests/playwrightSetup.ts index 79ae05bb45..34d182ffbe 100644 --- a/website/tests/playwrightSetup.ts +++ b/website/tests/playwrightSetup.ts @@ -1,11 +1,16 @@ -import { isErrorFromAlias } from '@zodios/core'; import isEqual from 'lodash/isEqual.js'; import sortBy from 'lodash/sortBy.js'; -import { e2eLogger, getToken, lapisUrl, testUser, testUserPassword } from './e2e.fixture.ts'; -import { addUserToGroup, createGroup } from './util/backendCalls.ts'; +import { + createTestGroupIfNotExistent, + DEFAULT_GROUP_NAME, + e2eLogger, + getToken, + lapisUrl, + testUser, + testUserPassword, +} from './e2e.fixture.ts'; import { prepareDataToBe } from './util/prepareDataToBe.ts'; -import { groupManagementApi } from '../src/services/groupManagementApi.ts'; import { LapisClient } from '../src/services/lapisClient.ts'; import { ACCESSION_FIELD, IS_REVOCATION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD } from '../src/settings.ts'; import { siloVersionStatuses } from '../src/types/lapis.ts'; @@ -15,17 +20,18 @@ enum LapisStateBeforeTests { CorrectSequencesInLapis = 'CorrectSequencesInLapis', } -export const DEFAULT_GROUP_NAME = 'testGroup'; - export default async function globalSetupForPlaywright() { const secondsToWait = 10; const maxNumberOfRetries = 12; - e2eLogger.info(`logging in as '${testUser}' + playwright users. Setup testGroups.`); + e2eLogger.info( + 'Setting up E2E tests. In order to test search results, data will be prepared in LAPIS. ' + + 'This preparation may take a few minutes and is done before to allow faster testing of search results.', + ); + e2eLogger.info(`Setup ${DEFAULT_GROUP_NAME}. Logging in as '${testUser}' to create the group.`); const token = (await getToken(testUser, testUserPassword)).accessToken; await createTestGroupIfNotExistent(token); - await addTestuserToTestGroupIfNotExistent(token); const lapisClient = LapisClient.create( lapisUrl, @@ -44,7 +50,8 @@ export default async function globalSetupForPlaywright() { if (lapisState === LapisStateBeforeTests.CorrectSequencesInLapis) { e2eLogger.info( - 'Skipping data preparation. NOTE: data preparation has to be done before on an empty LAPIS. Expected data found.', + 'Skipping data preparation. ' + + 'NOTE: data preparation has to be done before on an empty LAPIS. Expected data found.', ); return; } @@ -177,31 +184,3 @@ async function checkLapisState(lapisClient: LapisClient): Promise { +import { + backendClient, + DEFAULT_GROUP_NAME, + dummyOrganism, + groupManagementClient, + testSequenceCount, +} from '../e2e.fixture.ts'; + +export const submitViaApi = async ( + numberOfSequenceEntries: number = testSequenceCount, + token: string, + groupName: string = DEFAULT_GROUP_NAME, +) => { const fileContent = createFileContent(numberOfSequenceEntries); const response = await backendClient.call( @@ -12,7 +21,7 @@ export const submitViaApi = async (numberOfSequenceEntries: number = testSequenc { metadataFile: new File([fileContent.metadataContent], 'metadata.tsv'), sequenceFile: new File([fileContent.sequenceFileContent], 'sequences.fasta'), - groupName: DEFAULT_GROUP_NAME, + groupName, dataUseTermsType: openDataUseTermsType, restrictedUntil: null, }, @@ -50,7 +59,8 @@ export const submitRevisedDataViaApi = async (accessions: Accession[], token: st export const approveProcessedData = async (accessionVersions: AccessionVersion[], token: string): Promise => { const body = { - accessionVersions, + accessionVersionsFilter: accessionVersions, + scope: 'ALL' as const, }; const response = await backendClient.call('approveProcessedData', body, { @@ -104,10 +114,3 @@ export const createGroup = async (newGroupName: string = DEFAULT_GROUP_NAME, tok }, ); }; - -export const addUserToGroup = async (groupName: string = DEFAULT_GROUP_NAME, usernameToAdd: string, token: string) => { - await groupManagementClient.zodios.addUserToGroup(undefined, { - params: { groupName, userToAdd: usernameToAdd }, - headers: createAuthorizationHeader(token), - }); -}; diff --git a/website/tests/util/prepareDataToBe.ts b/website/tests/util/prepareDataToBe.ts index 34844dc20d..137d7e5528 100644 --- a/website/tests/util/prepareDataToBe.ts +++ b/website/tests/util/prepareDataToBe.ts @@ -2,39 +2,48 @@ import { approveProcessedData, revokeReleasedData, submitRevisedDataViaApi, subm import { fakeProcessingPipeline, type PreprocessingOptions } from './preprocessingPipeline.ts'; import type { AccessionVersion } from '../../src/types/backend.ts'; import { extractAccessionVersion } from '../../src/utils/extractAccessionVersion.ts'; -import { testSequenceCount } from '../e2e.fixture.ts'; +import { DEFAULT_GROUP_NAME, testSequenceCount } from '../e2e.fixture.ts'; export const prepareDataToBe = ( state: 'approvedForRelease' | 'erroneous' | 'awaitingApproval' | 'revoked' | 'revisedForRelease', token: string, numberOfSequenceEntries: number = testSequenceCount, + groupName: string = DEFAULT_GROUP_NAME, ): Promise => { switch (state) { case 'approvedForRelease': - return prepareDataToBeApprovedForRelease(numberOfSequenceEntries, token); + return prepareDataToBeApprovedForRelease(numberOfSequenceEntries, token, groupName); case 'erroneous': - return prepareDataToHaveErrors(numberOfSequenceEntries, token); + return prepareDataToHaveErrors(numberOfSequenceEntries, token, groupName); case 'awaitingApproval': - return prepareDataToBeAwaitingApproval(numberOfSequenceEntries, token); + return prepareDataToBeAwaitingApproval(numberOfSequenceEntries, token, groupName); case 'revoked': - return prepareDataToBeRevoked(numberOfSequenceEntries, token); + return prepareDataToBeRevoked(numberOfSequenceEntries, token, groupName); case 'revisedForRelease': - return prepareDataToBeRevisedForRelease(numberOfSequenceEntries, token); + return prepareDataToBeRevisedForRelease(numberOfSequenceEntries, token, groupName); } }; const absurdlyManySoThatAllSequencesAreInProcessing = 10_000; -async function prepareDataToBeProcessing(numberOfSequenceEntries: number, token: string) { - const submittedSequences = await submitViaApi(numberOfSequenceEntries, token); +async function prepareDataToBeProcessing( + numberOfSequenceEntries: number, + token: string, + groupName: string = DEFAULT_GROUP_NAME, +) { + const submittedSequences = await submitViaApi(numberOfSequenceEntries, token, groupName); await fakeProcessingPipeline.query(absurdlyManySoThatAllSequencesAreInProcessing); return submittedSequences; } -const prepareDataToHaveErrors = async (numberOfSequenceEntries: number = testSequenceCount, token: string) => { - const sequenceEntries = await prepareDataToBeProcessing(numberOfSequenceEntries, token); +const prepareDataToHaveErrors = async ( + numberOfSequenceEntries: number = testSequenceCount, + token: string, + groupName: string = DEFAULT_GROUP_NAME, +) => { + const sequenceEntries = await prepareDataToBeProcessing(numberOfSequenceEntries, token, groupName); const options: PreprocessingOptions[] = sequenceEntries .map(extractAccessionVersion) @@ -44,8 +53,12 @@ const prepareDataToHaveErrors = async (numberOfSequenceEntries: number = testSeq return sequenceEntries; }; -const prepareDataToBeAwaitingApproval = async (numberOfSequenceEntries: number = testSequenceCount, token: string) => { - const sequenceEntries = await prepareDataToBeProcessing(numberOfSequenceEntries, token); +const prepareDataToBeAwaitingApproval = async ( + numberOfSequenceEntries: number = testSequenceCount, + token: string, + groupName: string = DEFAULT_GROUP_NAME, +) => { + const sequenceEntries = await prepareDataToBeProcessing(numberOfSequenceEntries, token, groupName); const options: PreprocessingOptions[] = sequenceEntries.map((sequence) => ({ ...sequence, error: false })); await fakeProcessingPipeline.submit(options); @@ -56,16 +69,21 @@ const prepareDataToBeAwaitingApproval = async (numberOfSequenceEntries: number = const prepareDataToBeApprovedForRelease = async ( numberOfSequenceEntries: number = testSequenceCount, token: string, + groupName: string = DEFAULT_GROUP_NAME, ) => { - const sequenceEntries = await prepareDataToBeAwaitingApproval(numberOfSequenceEntries, token); + const sequenceEntries = await prepareDataToBeAwaitingApproval(numberOfSequenceEntries, token, groupName); await approveProcessedData(sequenceEntries, token); return sequenceEntries; }; -const prepareDataToBeRevoked = async (numberOfSequenceEntries: number = testSequenceCount, token: string) => { - const sequenceEntries = await prepareDataToBeApprovedForRelease(numberOfSequenceEntries, token); +const prepareDataToBeRevoked = async ( + numberOfSequenceEntries: number = testSequenceCount, + token: string, + groupName: string = DEFAULT_GROUP_NAME, +) => { + const sequenceEntries = await prepareDataToBeApprovedForRelease(numberOfSequenceEntries, token, groupName); return revokeReleasedData( sequenceEntries.map((entry) => entry.accession), @@ -73,8 +91,12 @@ const prepareDataToBeRevoked = async (numberOfSequenceEntries: number = testSequ ); }; -const prepareDataToBeRevisedForRelease = async (numberOfSequenceEntries: number = testSequenceCount, token: string) => { - const sequenceEntries = await prepareDataToBeApprovedForRelease(numberOfSequenceEntries, token); +const prepareDataToBeRevisedForRelease = async ( + numberOfSequenceEntries: number = testSequenceCount, + token: string, + groupName: string = DEFAULT_GROUP_NAME, +) => { + const sequenceEntries = await prepareDataToBeApprovedForRelease(numberOfSequenceEntries, token, groupName); const submittedRevisionAccessionVersion = await submitRevisedDataViaApi( sequenceEntries.map((entry) => entry.accession), diff --git a/website/vitest.setup.ts b/website/vitest.setup.ts index c55f78ae60..643b0a9e12 100755 --- a/website/vitest.setup.ts +++ b/website/vitest.setup.ts @@ -9,7 +9,8 @@ import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'; import type { GetSequencesResponse, SequenceEntryToEdit, SubmissionIdMapping } from './src/types/backend.ts'; import type { DetailsResponse, InsertionsResponse, LapisError, MutationsResponse } from './src/types/lapis.ts'; import type { RuntimeConfig } from './src/types/runtimeConfig.ts'; -import { DEFAULT_GROUP_NAME } from './tests/playwrightSetup.ts'; + +export const DEFAULT_GROUP_NAME = 'testGroup'; export const testConfig = { public: {