diff --git a/backend/src/main/kotlin/org/loculus/backend/config/Config.kt b/backend/src/main/kotlin/org/loculus/backend/config/Config.kt index 7131c36780..afecf6f9f7 100644 --- a/backend/src/main/kotlin/org/loculus/backend/config/Config.kt +++ b/backend/src/main/kotlin/org/loculus/backend/config/Config.kt @@ -6,6 +6,7 @@ import org.loculus.backend.api.Organism data class BackendConfig( val instances: Map, + val accessionPrefix: String, ) { fun getInstanceConfig(organism: Organism) = instances[organism.name] ?: throw IllegalArgumentException( "Organism: ${organism.name} not found in backend config. Available organisms: ${instances.keys}", diff --git a/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt b/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt index f42393b8b8..d161149ab6 100644 --- a/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt +++ b/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt @@ -117,6 +117,13 @@ class SubmitModel( submissionParams.organism, submissionParams.username, ) + } else if (submissionParams is SubmissionParams.OriginalSubmissionParams) { + log.info { "Generating new accessions for uploaded sequence data with uploadId $uploadId" } + uploadDatabaseService.generateNewAccessionsForOriginalUpload( + uploadId, + submissionParams.organism, + submissionParams.username, + ) } log.debug { "Persisting submission with uploadId $uploadId" } diff --git a/backend/src/main/kotlin/org/loculus/backend/service/GenerateAccessionFromNumberService.kt b/backend/src/main/kotlin/org/loculus/backend/service/GenerateAccessionFromNumberService.kt new file mode 100644 index 0000000000..c9018277a6 --- /dev/null +++ b/backend/src/main/kotlin/org/loculus/backend/service/GenerateAccessionFromNumberService.kt @@ -0,0 +1,82 @@ +package org.loculus.backend.service + +import org.loculus.backend.config.BackendConfig +import org.loculus.backend.utils.Accession +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service + +@Service +class GenerateAccessionFromNumberService( + @Autowired val backendConfig: BackendConfig, +) { + + fun generateCustomId(sequenceNumber: Long): String { + val base34Digits: MutableList = mutableListOf() + var remainder: Long = sequenceNumber + + do { + val digit = (remainder % 34).toInt() + base34Digits.addFirst(CODE_POINTS[digit]) + remainder /= 34 + } while (remainder > 0) + + val serialAccessionPart = base34Digits + .joinToString("") + .padStart(6, '0') + return backendConfig.accessionPrefix + serialAccessionPart + generateCheckCharacter(serialAccessionPart) + } + + fun validateAccession(accession: Accession): Boolean { + if (!accession.startsWith(backendConfig.accessionPrefix)) { + return false + } + return validateCheckCharacter(accession.removePrefix(backendConfig.accessionPrefix)) + } + + // See https://en.wikipedia.org/wiki/Luhn_mod_N_algorithm for details + private fun generateCheckCharacter(input: String): Char { + var factor = 2 + var sum = 0 + + for (i in input.length - 1 downTo 0) { + var addend = factor * getCodePointFromCharacter(input[i]) + + factor = if (factor == 2) 1 else 2 + + addend = addend / NUMBER_OF_VALID_CHARACTERS + addend % NUMBER_OF_VALID_CHARACTERS + sum += addend + } + + val remainder = sum % NUMBER_OF_VALID_CHARACTERS + val checkCodePoint = (NUMBER_OF_VALID_CHARACTERS - remainder) % NUMBER_OF_VALID_CHARACTERS + return CODE_POINTS[checkCodePoint] + } + + private fun validateCheckCharacter(input: String): Boolean { + var factor = 1 + var sum = 0 + + for (i in input.length - 1 downTo 0) { + val codePoint: Int = getCodePointFromCharacter(input[i]) + var addend = factor * codePoint + + factor = when (factor) { + 2 -> 1 + else -> 2 + } + + addend = addend / NUMBER_OF_VALID_CHARACTERS + addend % NUMBER_OF_VALID_CHARACTERS + sum += addend + } + val remainder = sum % NUMBER_OF_VALID_CHARACTERS + return remainder == 0 + } + + companion object { + const val CODE_POINTS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ" + fun getCodePointFromCharacter(character: Char): Int { + return CODE_POINTS.indexOf(character) + } + const val NUMBER_OF_VALID_CHARACTERS = CODE_POINTS.length + } +} diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt index 2294cc7580..10f6f70c2c 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt @@ -2,6 +2,7 @@ package org.loculus.backend.service.submission import kotlinx.datetime.LocalDateTime import mu.KotlinLogging +import org.jetbrains.exposed.sql.IntegerColumnType import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.VarCharColumnType import org.jetbrains.exposed.sql.and @@ -17,6 +18,7 @@ import org.loculus.backend.api.SubmissionIdMapping import org.loculus.backend.model.SubmissionId import org.loculus.backend.model.SubmissionParams import org.loculus.backend.model.UploadType +import org.loculus.backend.service.GenerateAccessionFromNumberService import org.loculus.backend.service.datauseterms.DataUseTermsDatabaseService import org.loculus.backend.service.submission.MetadataUploadAuxTable.accessionColumn import org.loculus.backend.service.submission.MetadataUploadAuxTable.groupNameColumn @@ -46,6 +48,7 @@ class UploadDatabaseService( private val compressor: CompressionService, private val accessionPreconditionValidator: AccessionPreconditionValidator, private val dataUseTermsDatabaseService: DataUseTermsDatabaseService, + private val generateAccessionFromNumberService: GenerateAccessionFromNumberService, ) { fun batchInsertMetadataInAuxTable( @@ -157,8 +160,6 @@ class UploadDatabaseService( } fun associateRevisedDataWithExistingSequenceEntries(uploadId: String, organism: Organism, username: String) { - log.info { "associating revised data with existing sequence entries with UploadId $uploadId" } - val accessions = MetadataUploadAuxTable .slice(accessionColumn) @@ -185,6 +186,45 @@ class UploadDatabaseService( } } + fun generateNewAccessionsForOriginalUpload(uploadId: String, organism: Organism, username: String) { + val submissionIds = + MetadataUploadAuxTable + .slice(submissionIdColumn) + .select { uploadIdColumn eq uploadId } + .map { it[submissionIdColumn] } + + val nextAccessions = getNextSequenceNumbers(submissionIds.size).map { + generateAccessionFromNumberService.generateCustomId(it) + } + + if (submissionIds.size != nextAccessions.size) { + throw IllegalStateException( + "Mismatched sizes: accessions=${submissionIds.size}, nextAccessions=${nextAccessions.size}", + ) + } + + val submissionIdToAccessionMap = submissionIds.zip(nextAccessions) + + log.info { + "Generated ${submissionIdToAccessionMap.size} new accessions for original upload with UploadId " + + "$uploadId: ${submissionIdToAccessionMap.joinToString( + limit = 10, + ){ + it.toString() + } }" + } + + submissionIdToAccessionMap.forEach { (submissionId, accession) -> + MetadataUploadAuxTable.update( + where = { + (submissionIdColumn eq submissionId) and (uploadIdColumn eq uploadId) + }, + ) { + it[accessionColumn] = accession + } + } + } + private fun generateMapAndCopyStatement(uploadType: UploadType): String { val commonColumns = StringBuilder().apply { append("accession,") @@ -205,7 +245,9 @@ class UploadDatabaseService( }.toString() val specificColumns = if (uploadType == UploadType.ORIGINAL) { - "nextval('accession_sequence')," + """ + m.accession, + """.trimIndent() } else { """ m.accession, @@ -244,4 +286,24 @@ class UploadDatabaseService( RETURNING accession, version, submission_id; """.trimIndent() } + + fun getNextSequenceNumbers(numberOfNewEntries: Int) = transaction { + val nextValues = exec( + "SELECT nextval('accession_sequence') FROM generate_series(1, ?)", + listOf( + Pair(IntegerColumnType(), numberOfNewEntries), + ), + ) { rs -> + val result = mutableListOf() + while (rs.next()) { + result += rs.getLong(1) + } + result.toList() + } ?: emptyList() + + if (nextValues.size != numberOfNewEntries) { + throw IllegalStateException("Expected $numberOfNewEntries values, got ${nextValues.size}.") + } + nextValues + } } diff --git a/backend/src/main/kotlin/org/loculus/backend/utils/MetadataEntry.kt b/backend/src/main/kotlin/org/loculus/backend/utils/MetadataEntry.kt index 5d304a880f..d68d50ea23 100644 --- a/backend/src/main/kotlin/org/loculus/backend/utils/MetadataEntry.kt +++ b/backend/src/main/kotlin/org/loculus/backend/utils/MetadataEntry.kt @@ -87,12 +87,6 @@ fun revisionEntryStreamAsSequence(metadataInputStream: InputStream): Sequence column != HEADER_TO_CONNECT_METADATA_AND_SEQUENCES && column != ACCESSION_HEADER diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/datauseterms/DataUseTermsControllerTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/datauseterms/DataUseTermsControllerTest.kt index c4e9f4c88e..003ed2341e 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/datauseterms/DataUseTermsControllerTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/datauseterms/DataUseTermsControllerTest.kt @@ -15,7 +15,6 @@ import org.loculus.backend.api.DataUseTermsType import org.loculus.backend.controller.EndpointTest import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.submission.SubmissionConvenienceClient -import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.firstAccession import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.test.web.servlet.ResultActions @@ -34,30 +33,34 @@ class DataUseTermsControllerTest( @Test fun `WHEN I get data use terms of non-existing accession THEN returns unprocessable entity`() { - client.getDataUseTerms(firstAccession) + val nonExistingAccession = "SomeNonExistingAccession" + + client.getDataUseTerms(nonExistingAccession) .andExpect(status().isNotFound) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( - jsonPath("\$.detail", containsString("Accession $firstAccession not found")), + jsonPath("\$.detail", containsString("Accession $nonExistingAccession not found")), ) } @Test fun `GIVEN open submission WHEN getting data use terms THEN return history with one OPEN entry`() { - submissionConvenienceClient.submitDefaultFiles() + val firstAccession = submissionConvenienceClient.submitDefaultFiles().first().accession client.getDataUseTerms(firstAccession) .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$").isArray) .andExpect(jsonPath("\$").isNotEmpty) - .andExpect(jsonPath("\$[0].accession").value("1")) + .andExpect(jsonPath("\$[0].accession").value(firstAccession)) .andExpect(jsonPath("\$[0].changeDate", containsString(dateMonthsFromNow(0).toString()))) .andExpect(jsonPath("\$[0].dataUseTerms.type").value(DataUseTermsType.OPEN.name)) } @Test fun `GIVEN changes in data use terms WHEN getting data use terms THEN return full history`() { - submissionConvenienceClient.submitDefaultFiles(dataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6))) + val firstAccession = submissionConvenienceClient.submitDefaultFiles( + dataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)), + ).first().accession client.changeDataUseTerms( DataUseTermsChangeRequest( @@ -100,10 +103,17 @@ class DataUseTermsControllerTest( @ParameterizedTest @MethodSource("dataUseTermsTestCases") - fun `test data use terms changes`(testCase: DataUseTermsTestCase) { - submissionConvenienceClient.submitDefaultFiles(dataUseTerms = testCase.setupDataUseTerms) + fun `WHEN changing data use terms THEN show success or error`(testCase: DataUseTermsTestCase) { + val accessions = submissionConvenienceClient + .submitDefaultFiles(dataUseTerms = testCase.setupDataUseTerms) + .map { it.accession } - val result = client.changeDataUseTerms(testCase.changeRequest) + val result = client.changeDataUseTerms( + DataUseTermsChangeRequest( + accessions = accessions, + newDataUseTerms = testCase.newDataUseTerms, + ), + ) .andExpect(testCase.expectedStatus) if (testCase.expectedContentType != null && testCase.expectedDetailContains != null) { @@ -134,7 +144,7 @@ class DataUseTermsControllerTest( data class DataUseTermsTestCase( val setupDataUseTerms: DataUseTerms, - val changeRequest: DataUseTermsChangeRequest, + val newDataUseTerms: DataUseTerms, val expectedStatus: ResultMatcher, val expectedContentType: String?, val expectedDetailContains: String?, @@ -145,27 +155,21 @@ class DataUseTermsControllerTest( return listOf( DataUseTermsTestCase( setupDataUseTerms = DataUseTerms.Open, - changeRequest = DEFAULT_DATA_USE_CHANGE_REQUEST, + newDataUseTerms = DataUseTerms.Open, expectedStatus = status().isNoContent, expectedContentType = null, expectedDetailContains = null, ), DataUseTermsTestCase( setupDataUseTerms = DataUseTerms.Open, - changeRequest = DataUseTermsChangeRequest( - accessions = listOf("1", "2"), - newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)), - ), + newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)), expectedStatus = status().isUnprocessableEntity, expectedContentType = MediaType.APPLICATION_JSON_VALUE, expectedDetailContains = "Cannot change data use terms from OPEN to RESTRICTED.", ), DataUseTermsTestCase( setupDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)), - changeRequest = DataUseTermsChangeRequest( - accessions = listOf("1", "2"), - newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(-1)), - ), + newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(-1)), expectedStatus = status().isBadRequest, expectedContentType = MediaType.APPLICATION_JSON_VALUE, expectedDetailContains = "The date 'restrictedUntil' must be in the future, " + @@ -173,10 +177,7 @@ class DataUseTermsControllerTest( ), DataUseTermsTestCase( setupDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)), - changeRequest = DataUseTermsChangeRequest( - accessions = listOf("1", "2"), - newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(7)), - ), + newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(7)), expectedStatus = status().isUnprocessableEntity, expectedContentType = MediaType.APPLICATION_JSON_VALUE, expectedDetailContains = "Cannot extend restricted data use period. " + 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 e8aae3c46d..36c7eef1d6 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 @@ -3,6 +3,7 @@ package org.loculus.backend.controller.submission import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.`is` import org.junit.jupiter.api.Test import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.ApproveDataScope @@ -41,23 +42,15 @@ class ApproveProcessedDataEndpointTest( @Test fun `GIVEN sequence entries are processed WHEN I approve them THEN their status should be APPROVED_FOR_RELEASE`() { - convenienceClient.prepareDatabaseWithProcessedData( - PreparedProcessedData.successfullyProcessed(accession = "1"), - PreparedProcessedData.successfullyProcessed(accession = "2"), - ) + val accessionVersions = convenienceClient.prepareDataTo(AWAITING_APPROVAL).getAccessionVersions() - client.approveProcessedSequenceEntries( - listOf( - AccessionVersion("1", 1), - AccessionVersion("2", 1), - ), - ) + client.approveProcessedSequenceEntries(accessionVersions) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = "1", version = 1) - .assertStatusIs(APPROVED_FOR_RELEASE) - convenienceClient.getSequenceEntryOfUser(accession = "2", version = 1) - .assertStatusIs(APPROVED_FOR_RELEASE) + assertThat( + convenienceClient.getSequenceEntries().statusCounts[APPROVED_FOR_RELEASE], + `is`(NUMBER_OF_SEQUENCES), + ) } @Test @@ -75,18 +68,9 @@ class ApproveProcessedDataEndpointTest( @Test fun `WHEN I approve sequence entries as non-group member THEN it should fail as forbidden`() { - convenienceClient.prepareDatabaseWithProcessedData( - PreparedProcessedData.successfullyProcessed(accession = "1"), - PreparedProcessedData.successfullyProcessed(accession = "2"), - ) + val accessionVersions = convenienceClient.prepareDataTo(AWAITING_APPROVAL).getAccessionVersions() - client.approveProcessedSequenceEntries( - listOf( - AccessionVersion("1", 1), - AccessionVersion("2", 1), - ), - jwt = generateJwtFor("other user"), - ) + client.approveProcessedSequenceEntries(accessionVersions, jwt = generateJwtFor("other user")) .andExpect(status().isForbidden) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect( @@ -98,7 +82,7 @@ class ApproveProcessedDataEndpointTest( .andExpect( jsonPath( "$.detail", - containsString("Affected AccessionVersions: [1.1, 2.1]"), + containsString("Affected AccessionVersions"), ), ) } @@ -107,13 +91,11 @@ class ApproveProcessedDataEndpointTest( fun `WHEN I approve a sequence entry that does not exist THEN no accession should be approved`() { val nonExistentAccession = "999" - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.successfullyProcessed(accession = "1")) - - val existingAccessionVersion = AccessionVersion("1", 1) + val accessionVersions = convenienceClient.prepareDataTo(AWAITING_APPROVAL).getAccessionVersions() client.approveProcessedSequenceEntries( listOf( - existingAccessionVersion, + accessionVersions.first(), AccessionVersion(nonExistentAccession, 1), ), ) @@ -121,61 +103,66 @@ class ApproveProcessedDataEndpointTest( .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.detail", containsString("Accession versions 999.1 do not exist"))) - convenienceClient.getSequenceEntryOfUser(existingAccessionVersion).assertStatusIs(AWAITING_APPROVAL) + convenienceClient.getSequenceEntryOfUser(accessionVersions.first()).assertStatusIs(AWAITING_APPROVAL) } @Test fun `WHEN I approve a sequence entry that does not exist THEN no sequence should be approved`() { - val nonExistentVersion = 999L - - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.successfullyProcessed(accession = "1")) - - val existingAccessionVersion = AccessionVersion("1", 1) + val accessionVersions = convenienceClient.prepareDataTo(AWAITING_APPROVAL).getAccessionVersions() + val nonExistingVersion = accessionVersions[1].copy(version = 999L) client.approveProcessedSequenceEntries( listOf( - existingAccessionVersion, - AccessionVersion("1", nonExistentVersion), + accessionVersions.first(), + nonExistingVersion, ), ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.detail", containsString("Accession versions 1.999 do not exist"))) + .andExpect( + jsonPath( + "$.detail", + containsString( + "Accession versions ${nonExistingVersion.displayAccessionVersion()} " + + "do not exist", + ), + ), + ) - convenienceClient.getSequenceEntryOfUser(existingAccessionVersion).assertStatusIs(AWAITING_APPROVAL) + convenienceClient.getSequenceEntryOfUser(accessionVersions.first()).assertStatusIs(AWAITING_APPROVAL) } @Test fun `GIVEN one of the entries is not processed WHEN I approve them THEN no sequence should be approved`() { - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.successfullyProcessed(accession = "1")) + val accessionVersionsInCorrectState = convenienceClient.prepareDataTo(AWAITING_APPROVAL).getAccessionVersions() + val accessionVersionNotInCorrectState = convenienceClient.prepareDataTo(IN_PROCESSING).getAccessionVersions() - val accessionVersionInCorrectState = AccessionVersion("1", 1) - - convenienceClient.getSequenceEntryOfUser(accessionVersionInCorrectState) + convenienceClient.getSequenceEntryOfUser(accessionVersionsInCorrectState.first()) .assertStatusIs(AWAITING_APPROVAL) - convenienceClient.getSequenceEntryOfUser(accession = "2", version = 1).assertStatusIs(IN_PROCESSING) + convenienceClient.getSequenceEntryOfUser(accessionVersionNotInCorrectState.first()).assertStatusIs( + IN_PROCESSING, + ) client.approveProcessedSequenceEntries( - listOf( - accessionVersionInCorrectState, - AccessionVersion("2", 1), - AccessionVersion("3", 1), - ), + accessionVersionsInCorrectState + accessionVersionNotInCorrectState, ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect( - jsonPath("$.detail") - .value( + jsonPath( + "$.detail", + containsString( "Accession versions are in not in one of the states [$AWAITING_APPROVAL]: " + - "2.1 - $IN_PROCESSING, 3.1 - $IN_PROCESSING", + "${accessionVersionNotInCorrectState.first().displayAccessionVersion()} - $IN_PROCESSING", ), + ), ) - convenienceClient.getSequenceEntryOfUser(accessionVersionInCorrectState) + convenienceClient.getSequenceEntryOfUser(accessionVersionsInCorrectState.first()) .assertStatusIs(AWAITING_APPROVAL) - convenienceClient.getSequenceEntryOfUser(accession = "2", version = 1).assertStatusIs(IN_PROCESSING) - convenienceClient.getSequenceEntryOfUser(accession = "3", version = 1).assertStatusIs(IN_PROCESSING) + convenienceClient.getSequenceEntryOfUser(accessionVersionNotInCorrectState.first()).assertStatusIs( + IN_PROCESSING, + ) } @Test @@ -194,9 +181,17 @@ class ApproveProcessedDataEndpointTest( .value(containsString("accession versions are not of organism otherOrganism")), ) - convenienceClient.getSequenceEntryOfUser(accession = "1", version = 1, organism = DEFAULT_ORGANISM) + convenienceClient.getSequenceEntryOfUser( + accession = defaultOrganismData.first().accession, + version = 1, + organism = DEFAULT_ORGANISM, + ) .assertStatusIs(AWAITING_APPROVAL) - convenienceClient.getSequenceEntryOfUser(accession = "11", version = 1, organism = OTHER_ORGANISM) + convenienceClient.getSequenceEntryOfUser( + accession = otherOrganismData.first().accession, + version = 1, + organism = OTHER_ORGANISM, + ) .assertStatusIs(AWAITING_APPROVAL) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ConfirmRevocationEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ConfirmRevocationEndpointTest.kt index 7f051de712..cc35cb72d7 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ConfirmRevocationEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ConfirmRevocationEndpointTest.kt @@ -4,6 +4,7 @@ import org.hamcrest.Matchers.containsString import org.junit.jupiter.api.Test import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE +import org.loculus.backend.api.Status.AWAITING_APPROVAL import org.loculus.backend.api.Status.AWAITING_APPROVAL_FOR_REVOCATION import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.EndpointTest @@ -11,6 +12,7 @@ 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.getAccessionVersions import org.loculus.backend.controller.toAccessionVersion import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType @@ -36,25 +38,18 @@ class ConfirmRevocationEndpointTest( @Test fun `GIVEN sequence entries with status 'FOR_REVOCATION' THEN the status changes to 'APPROVED_FOR_RELEASE'`() { - convenienceClient.prepareDefaultSequenceEntriesToAwaitingApprovalForRevocation() + val accessionVersions = convenienceClient.prepareDataTo(AWAITING_APPROVAL_FOR_REVOCATION).getAccessionVersions() - client.confirmRevocation( - listOf( - AccessionVersion("1", 2), - AccessionVersion("2", 2), - ), - ) + client.confirmRevocation(accessionVersions) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = "1", version = 2) - .assertStatusIs(APPROVED_FOR_RELEASE) - convenienceClient.getSequenceEntryOfUser(accession = "2", version = 2) + convenienceClient.getSequenceEntryOfUser(accession = accessionVersions.first().accession, version = 2) .assertStatusIs(APPROVED_FOR_RELEASE) } @Test fun `WHEN confirming revocation of non-existing accessionVersions THEN throws an unprocessableEntity error`() { - convenienceClient.prepareDefaultSequenceEntriesToAwaitingApprovalForRevocation() + convenienceClient.prepareDataTo(AWAITING_APPROVAL_FOR_REVOCATION) val nonExistingAccession = AccessionVersion("123", 2) val nonExistingVersion = AccessionVersion("1", 123) @@ -70,10 +65,10 @@ class ConfirmRevocationEndpointTest( @Test fun `WHEN confirming revocation of other organism THEN throws an unprocessableEntity error`() { val revokedAccessionVersion = - convenienceClient.prepareDataTo(AWAITING_APPROVAL_FOR_REVOCATION, organism = DEFAULT_ORGANISM)[0] + convenienceClient.prepareDataTo(AWAITING_APPROVAL_FOR_REVOCATION).first().toAccessionVersion() client.confirmRevocation( - listOf(revokedAccessionVersion.toAccessionVersion()), + listOf(revokedAccessionVersion), organism = OTHER_ORGANISM, ) .andExpect(status().isUnprocessableEntity) @@ -85,16 +80,11 @@ class ConfirmRevocationEndpointTest( @Test fun `WHEN confirming revocation for accessionVersions not from the submitter THEN throws forbidden error`() { - convenienceClient.prepareDefaultSequenceEntriesToAwaitingApprovalForRevocation() + val accessionVersions = convenienceClient + .prepareDataTo(AWAITING_APPROVAL_FOR_REVOCATION, organism = DEFAULT_ORGANISM).getAccessionVersions() val notSubmitter = "notTheSubmitter" - client.confirmRevocation( - listOf( - AccessionVersion("1", 2), - AccessionVersion("2", 2), - ), - jwt = generateJwtFor(notSubmitter), - ) + client.confirmRevocation(accessionVersions, jwt = generateJwtFor(notSubmitter)) .andExpect(status().isForbidden) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( @@ -104,21 +94,25 @@ class ConfirmRevocationEndpointTest( @Test fun `WHEN I confirm a revocation versions with latest version not 'APPROVED_FOR_RELEASE' THEN throws an error`() { - convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease() + val accessionVersions = convenienceClient + .prepareDataTo(AWAITING_APPROVAL) + .getAccessionVersions() - client.confirmRevocation( - listOf( - AccessionVersion("1", 1), - AccessionVersion("2", 1), - ), - ) + val revocationAccessionVersions = convenienceClient + .prepareDataTo(AWAITING_APPROVAL_FOR_REVOCATION) + .getAccessionVersions() + + client.confirmRevocation(accessionVersions + revocationAccessionVersions) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( - jsonPath("\$.detail").value( - "Accession versions are in not in one of the states [" + - "${AWAITING_APPROVAL_FOR_REVOCATION.name}]: " + - "1.1 - ${APPROVED_FOR_RELEASE.name}, 2.1 - ${APPROVED_FOR_RELEASE.name}", + jsonPath( + "\$.detail", + containsString( + "Accession versions are in not in one of the states [" + + "${AWAITING_APPROVAL_FOR_REVOCATION.name}]: " + + "${accessionVersions.first().displayAccessionVersion()} - ${AWAITING_APPROVAL.name}", + ), ), ) } 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 918a070674..8539c73c28 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 @@ -21,6 +21,7 @@ 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.getAccessionVersions import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES import org.loculus.backend.controller.toAccessionVersion import org.loculus.backend.utils.AccessionVersionComparator @@ -67,7 +68,10 @@ class DeleteSequencesEndpointTest( .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(accessionVersionsToDelete.size)) - .andExpect(jsonPath("\$[*].accession").value(accessionVersionsToDelete.map { it.accession })) + + accessionVersionsToDelete.forEach { + deletionResult.andExpect(jsonPath("\$[*].accession", hasItem(it.accession))) + } assertThat( convenienceClient.getSequenceEntriesOfUserInState( @@ -180,7 +184,7 @@ class DeleteSequencesEndpointTest( @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() + val sequenceWithWarning = PreparedProcessedData.withWarnings(originalSubmission.first().accession) convenienceClient.submitProcessedData(sequenceWithWarning) val countOfSequenceEntriesWithWarnings = 1 @@ -219,14 +223,11 @@ class DeleteSequencesEndpointTest( @Test fun `WHEN deleting accession versions not from the submitter THEN throws forbidden error`() { - convenienceClient.submitDefaultFiles() + val accessionVersions = convenienceClient.submitDefaultFiles().getAccessionVersions() val notSubmitter = "theOneWhoMustNotBeNamed" client.deleteSequenceEntries( - listOfAccessionVersionsToDelete = listOf( - AccessionVersion("1", 1), - AccessionVersion("2", 1), - ), + accessionVersions, jwt = generateJwtFor(notSubmitter), ) .andExpect(status().isForbidden) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt index 259e86c5f1..fbf7b035b9 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt @@ -61,7 +61,7 @@ class ExtractUnprocessedDataEndpointTest( @Test fun `WHEN extracting unprocessed data THEN only previously not extracted sequence entries are returned`() { - convenienceClient.submitDefaultFiles() + val firstAccession = convenienceClient.submitDefaultFiles().first().accession val result7 = client.extractUnprocessedData(7) val responseBody7 = result7.expectNdjsonAndGetContent() @@ -69,14 +69,13 @@ class ExtractUnprocessedDataEndpointTest( assertThat( responseBody7, hasItem( - UnprocessedData(DefaultFiles.firstAccession, 1, defaultOriginalData), + UnprocessedData(firstAccession, 1, defaultOriginalData), ), ) val result3 = client.extractUnprocessedData(5) val responseBody3 = result3.expectNdjsonAndGetContent() assertThat(responseBody3, hasSize(3)) - assertThat(responseBody3[0].accession, `is`("8")) val result0 = client.extractUnprocessedData(DefaultFiles.NUMBER_OF_SEQUENCES) val responseBody0 = result0.expectNdjsonAndGetContent() diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetDataToEditEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetDataToEditEndpointTest.kt index 563ae38e5f..32f41d3584 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetDataToEditEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetDataToEditEndpointTest.kt @@ -16,7 +16,6 @@ import org.loculus.backend.controller.expectNdjsonAndGetContent 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.firstAccession import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -33,7 +32,7 @@ class GetDataToEditEndpointTest( fun `GIVEN invalid authorization token THEN returns 401 Unauthorized`() { expectUnauthorizedResponse { client.getSequenceEntryThatHasErrors( - firstAccession, + "ShouldNotMatterAtAll", 1, jwt = it, ) @@ -48,9 +47,9 @@ class GetDataToEditEndpointTest( @Test fun `GIVEN an entry has errors WHEN I extract the sequence data THEN I get all data to edit the entry`() { - convenienceClient.prepareDefaultSequenceEntriesToInProcessing() + val firstAccession = convenienceClient.prepareDefaultSequenceEntriesToInProcessing().first().accession - convenienceClient.submitProcessedData(PreparedProcessedData.withErrors()) + convenienceClient.submitProcessedData(PreparedProcessedData.withErrors(firstAccession)) convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) .assertStatusIs(Status.HAS_ERRORS) @@ -62,12 +61,12 @@ class GetDataToEditEndpointTest( assertThat(editedData.accession, `is`(firstAccession)) assertThat(editedData.version, `is`(1)) - assertThat(editedData.processedData, `is`(PreparedProcessedData.withErrors().data)) + assertThat(editedData.processedData, `is`(PreparedProcessedData.withErrors(firstAccession).data)) } @Test fun `WHEN I query data for a non-existent accession THEN refuses request with not found`() { - val nonExistentAccession = "999" + val nonExistentAccession = "DefinitelyNotExisting" client.getSequenceEntryThatHasErrors(nonExistentAccession, 1) .andExpect(status().isUnprocessableEntity) @@ -81,7 +80,10 @@ class GetDataToEditEndpointTest( @Test fun `WHEN I query data for wrong organism THEN refuses request with unprocessable entity`() { - convenienceClient.prepareDataTo(Status.HAS_ERRORS, organism = DEFAULT_ORGANISM) + val firstAccession = convenienceClient.prepareDataTo( + Status.HAS_ERRORS, + organism = DEFAULT_ORGANISM, + ).first().accession client.getSequenceEntryThatHasErrors(firstAccession, 1, organism = DEFAULT_ORGANISM) .andExpect(status().isOk) @@ -115,7 +117,7 @@ class GetDataToEditEndpointTest( @Test fun `WHEN I query a sequence entry that has a wrong state THEN refuses request with unprocessable entity`() { - convenienceClient.prepareDataTo(Status.IN_PROCESSING) + val firstAccession = convenienceClient.prepareDataTo(Status.IN_PROCESSING).first().accession client.getSequenceEntryThatHasErrors( accession = firstAccession, @@ -126,14 +128,14 @@ class GetDataToEditEndpointTest( .andExpect( jsonPath("\$.detail").value( "Accession versions are in not in one of the states " + - "[HAS_ERRORS, AWAITING_APPROVAL]: 1.1 - IN_PROCESSING", + "[HAS_ERRORS, AWAITING_APPROVAL]: $firstAccession.1 - IN_PROCESSING", ), ) } @Test fun `WHEN I try to get data for a sequence entry that I do not own THEN refuses request with forbidden entity`() { - convenienceClient.prepareDataTo(Status.HAS_ERRORS) + val firstAccession = convenienceClient.prepareDataTo(Status.HAS_ERRORS).first().accession val userNameThatDoesNotHavePermissionToQuery = "theOneWhoMustNotBeNamed" client.getSequenceEntryThatHasErrors( diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt index 299fd604e1..ea253faa2b 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt @@ -12,9 +12,9 @@ import org.hamcrest.CoreMatchers.`is` import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.matchesPattern import org.junit.jupiter.api.Test -import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.ProcessedData import org.loculus.backend.api.SiloVersionStatus +import org.loculus.backend.api.Status import org.loculus.backend.controller.DEFAULT_GROUP_NAME import org.loculus.backend.controller.EndpointTest import org.loculus.backend.controller.expectForbiddenResponse @@ -22,7 +22,6 @@ import org.loculus.backend.controller.expectNdjsonAndGetContent import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.jwtForDefaultUser import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles -import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.firstAccession import org.loculus.backend.utils.Accession import org.loculus.backend.utils.Version import org.springframework.beans.factory.annotation.Autowired @@ -114,6 +113,7 @@ class GetReleasedDataEndpointTest( @Test fun `GIVEN released data exists in multiple versions THEN the 'versionStatus' flag is set correctly`() { val ( + accession, revokedVersion1, revokedVersion2, revocationVersion3, @@ -123,25 +123,24 @@ class GetReleasedDataEndpointTest( val response = submissionControllerClient.getReleasedData().expectNdjsonAndGetContent() - assertThat(response.size, `is`(5 * DefaultFiles.NUMBER_OF_SEQUENCES)) assertThat( - response.findAccessionVersionStatus(firstAccession, revokedVersion1), + response.findAccessionVersionStatus(accession, revokedVersion1), `is`(SiloVersionStatus.REVOKED.name), ) assertThat( - response.findAccessionVersionStatus(firstAccession, revokedVersion2), + response.findAccessionVersionStatus(accession, revokedVersion2), `is`(SiloVersionStatus.REVOKED.name), ) assertThat( - response.findAccessionVersionStatus(firstAccession, revocationVersion3), + response.findAccessionVersionStatus(accession, revocationVersion3), `is`(SiloVersionStatus.REVISED.name), ) assertThat( - response.findAccessionVersionStatus(firstAccession, revisedVersion4), + response.findAccessionVersionStatus(accession, revisedVersion4), `is`(SiloVersionStatus.REVISED.name), ) assertThat( - response.findAccessionVersionStatus(firstAccession, latestVersion5), + response.findAccessionVersionStatus(accession, latestVersion5), `is`(SiloVersionStatus.LATEST_VERSION.name), ) } @@ -217,18 +216,18 @@ class GetReleasedDataEndpointTest( } private fun prepareRevokedAndRevocationAndRevisedVersions(): PreparedVersions { - convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease() - convenienceClient.reviseAndProcessDefaultSequenceEntries() + val preparedSubmissions = convenienceClient.prepareDataTo(Status.APPROVED_FOR_RELEASE) + convenienceClient.reviseAndProcessDefaultSequenceEntries(preparedSubmissions.map { it.accession }) - convenienceClient.revokeSequenceEntries(DefaultFiles.allAccessions) - convenienceClient.confirmRevocation( - DefaultFiles.allAccessions.map { AccessionVersion(accession = it, version = 3L) }, - ) + val revokedSequences = convenienceClient.revokeSequenceEntries(preparedSubmissions.map { it.accession }) + convenienceClient.confirmRevocation(revokedSequences) + + convenienceClient.reviseAndProcessDefaultSequenceEntries(revokedSequences.map { it.accession }) - convenienceClient.reviseAndProcessDefaultSequenceEntries() - convenienceClient.reviseAndProcessDefaultSequenceEntries() + convenienceClient.reviseAndProcessDefaultSequenceEntries(revokedSequences.map { it.accession }) return PreparedVersions( + accession = preparedSubmissions.first().accession, revokedVersion1 = 1L, revokedVersion2 = 2L, revocationVersion3 = 3L, @@ -252,6 +251,7 @@ private fun List.findAccessionVersionStatus(accession: Accession, } data class PreparedVersions( + val accession: Accession, val revokedVersion1: Version, val revokedVersion2: Version, val revocationVersion3: Version, 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 fe87eef4bc..05eaf6fd3d 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 @@ -28,7 +28,7 @@ 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.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.firstAccession +import org.loculus.backend.utils.Accession import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -143,8 +143,8 @@ class GetSequencesEndpointTest( @Test fun `GIVEN data with warnings WHEN I exclude warnings THEN expect no data returned`() { - convenienceClient.prepareDefaultSequenceEntriesToInProcessing() - convenienceClient.submitProcessedData(PreparedProcessedData.withWarnings()) + val accessions = convenienceClient.prepareDefaultSequenceEntriesToInProcessing().map { it.accession } + convenienceClient.submitProcessedData(PreparedProcessedData.withWarnings(accessions.first())) val sequencesInAwaitingApproval = convenienceClient.getSequenceEntries( username = ALTERNATIVE_DEFAULT_USER_NAME, @@ -205,14 +205,14 @@ class GetSequencesEndpointTest( @ParameterizedTest(name = "{arguments}") @MethodSource("provideStatusScenarios") fun `GIVEN database in prepared state THEN returns sequence entries in expected status`(scenario: Scenario) { - scenario.prepareDatabase(convenienceClient) + val accessions = scenario.prepareDatabase(convenienceClient) val sequencesOfUser = convenienceClient.getSequenceEntries( statusesFilter = listOf(scenario.expectedStatus), ).sequenceEntries val accessionVersionStatus = - sequencesOfUser.find { it.accession == firstAccession && it.version == scenario.expectedVersion } + sequencesOfUser.find { it.accession == accessions.first() && it.version == scenario.expectedVersion } assertThat(accessionVersionStatus?.status, `is`(scenario.expectedStatus)) assertThat(accessionVersionStatus?.isRevocation, `is`(scenario.expectedIsRevocation)) } @@ -222,28 +222,26 @@ class GetSequencesEndpointTest( fun provideStatusScenarios() = listOf( Scenario( setupDescription = "I submitted sequence entries", - prepareDatabase = { it.submitDefaultFiles() }, + prepareDatabase = { it.submitDefaultFiles().map { entry -> entry.accession } }, expectedStatus = RECEIVED, expectedIsRevocation = false, ), Scenario( setupDescription = "I started processing sequence entries", - prepareDatabase = { it.prepareDefaultSequenceEntriesToInProcessing() }, + prepareDatabase = { it.prepareDefaultSequenceEntriesToInProcessing().map { entry -> entry.accession } }, expectedStatus = IN_PROCESSING, expectedIsRevocation = false, ), Scenario( setupDescription = "I submitted sequence entries that have errors", - prepareDatabase = { it.prepareDefaultSequenceEntriesToHasErrors() }, + prepareDatabase = { it.prepareDefaultSequenceEntriesToHasErrors().map { entry -> entry.accession } }, expectedStatus = HAS_ERRORS, expectedIsRevocation = false, ), Scenario( setupDescription = "I submitted sequence entries that have been successfully processed", prepareDatabase = { - it.prepareDatabaseWithProcessedData( - PreparedProcessedData.successfullyProcessed(), - ) + it.prepareDataTo(AWAITING_APPROVAL).map { entry -> entry.accession } }, expectedStatus = AWAITING_APPROVAL, expectedIsRevocation = false, @@ -251,8 +249,9 @@ class GetSequencesEndpointTest( Scenario( setupDescription = "I submitted, processed and approved sequence entries", prepareDatabase = { - it.prepareDatabaseWithProcessedData(PreparedProcessedData.successfullyProcessed()) - it.approveProcessedSequenceEntries(listOf(AccessionVersion(firstAccession, 1))) + val accessionVersions = it.prepareDataTo(AWAITING_APPROVAL) + it.approveProcessedSequenceEntries(listOf(accessionVersions.first())) + accessionVersions.map { entry -> entry.accession } }, expectedStatus = APPROVED_FOR_RELEASE, expectedIsRevocation = false, @@ -260,9 +259,11 @@ class GetSequencesEndpointTest( Scenario( setupDescription = "I submitted a revocation", prepareDatabase = { - it.prepareDatabaseWithProcessedData(PreparedProcessedData.successfullyProcessed()) - it.approveProcessedSequenceEntries(listOf(AccessionVersion(firstAccession, 1))) - it.revokeSequenceEntries(listOf(firstAccession)) + val accessionVersions = it.prepareDataTo(AWAITING_APPROVAL) + it.approveProcessedSequenceEntries(listOf(accessionVersions.first())) + val accessions = accessionVersions.map { entry -> entry.accession } + it.revokeSequenceEntries(listOf(accessions.first())) + accessions }, expectedStatus = AWAITING_APPROVAL_FOR_REVOCATION, expectedIsRevocation = true, @@ -271,10 +272,12 @@ class GetSequencesEndpointTest( Scenario( setupDescription = "I approved a revocation", prepareDatabase = { - it.prepareDatabaseWithProcessedData(PreparedProcessedData.successfullyProcessed()) - it.approveProcessedSequenceEntries(listOf(AccessionVersion(firstAccession, 1))) - it.revokeSequenceEntries(listOf(firstAccession)) - it.confirmRevocation(listOf(AccessionVersion(firstAccession, 2))) + val accessionVersions = it.prepareDataTo(AWAITING_APPROVAL) + it.approveProcessedSequenceEntries(listOf(accessionVersions.first())) + val accessions = accessionVersions.map { entry -> entry.accession } + it.revokeSequenceEntries(listOf(accessions.first())) + it.confirmRevocation(listOf(AccessionVersion(accessions.first(), 2))) + accessions }, expectedStatus = APPROVED_FOR_RELEASE, expectedIsRevocation = true, @@ -286,7 +289,7 @@ class GetSequencesEndpointTest( data class Scenario( val setupDescription: String, val expectedVersion: Long = 1, - val prepareDatabase: (SubmissionConvenienceClient) -> Unit, + val prepareDatabase: (SubmissionConvenienceClient) -> List, val expectedStatus: Status, val expectedIsRevocation: Boolean, ) { diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt index aa360a4649..0d0b4fe4f5 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt @@ -12,7 +12,6 @@ import org.loculus.backend.api.PreprocessingAnnotationSourceType import org.loculus.backend.api.ProcessedData import org.loculus.backend.api.SegmentName import org.loculus.backend.api.SubmittedProcessedData -import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles import org.loculus.backend.utils.Accession import org.loculus.backend.utils.Version @@ -97,45 +96,38 @@ val defaultProcessedDataMultiSegmented = ProcessedData( ) private val defaultSuccessfulSubmittedData = SubmittedProcessedData( - accession = "1", + accession = "If a test result shows this, processed data was not prepared correctly.", version = 1, data = defaultProcessedData, errors = null, warnings = null, ) -private val defaultSuccessfulSubmittedDataMultiSegmented = SubmittedProcessedData( - accession = "1", - version = 1, +private val defaultSuccessfulSubmittedDataMultiSegmented = defaultSuccessfulSubmittedData.copy( data = defaultProcessedDataMultiSegmented, - errors = null, - warnings = null, ) object PreparedProcessedData { - fun successfullyProcessed( - accession: Accession = DefaultFiles.firstAccession, - version: Long = defaultSuccessfulSubmittedData.version, - ) = defaultSuccessfulSubmittedData.copy( - accession = accession, - version = version, - ) + fun successfullyProcessed(accession: Accession, version: Long = defaultSuccessfulSubmittedData.version) = + defaultSuccessfulSubmittedData.copy( + accession = accession, + version = version, + ) fun successfullyProcessedOtherOrganismData( - accession: Accession = DefaultFiles.firstAccession, + accession: Accession, version: Long = defaultSuccessfulSubmittedDataMultiSegmented.version, ) = defaultSuccessfulSubmittedDataMultiSegmented.copy( accession = accession, version = version, ) - fun withNullForFields(accession: Accession = DefaultFiles.firstAccession, fields: List) = - defaultSuccessfulSubmittedData.copy( - accession = accession, - data = defaultProcessedData.copy( - metadata = defaultProcessedData.metadata + fields.map { it to NullNode.instance }, - ), - ) + fun withNullForFields(accession: Accession, fields: List) = defaultSuccessfulSubmittedData.copy( + accession = accession, + data = defaultProcessedData.copy( + metadata = defaultProcessedData.metadata + fields.map { it to NullNode.instance }, + ), + ) fun withNullForSequences(accession: Accession, version: Version) = defaultSuccessfulSubmittedData.copy( accession = accession, @@ -148,7 +140,7 @@ object PreparedProcessedData { ) fun withMissingMetadataFields( - accession: Accession = DefaultFiles.firstAccession, + accession: Accession, version: Long = defaultSuccessfulSubmittedData.version, absentFields: List, ) = defaultSuccessfulSubmittedData.copy( @@ -159,111 +151,97 @@ object PreparedProcessedData { ), ) - fun withUnknownMetadataField(accession: Accession = DefaultFiles.firstAccession, fields: List) = - defaultSuccessfulSubmittedData.copy( - accession = accession, - data = defaultProcessedData.copy( - metadata = defaultProcessedData.metadata + fields.map { it to TextNode("value for $it") }, - ), - ) - - fun withMissingRequiredField(accession: Accession = DefaultFiles.firstAccession, fields: List) = - defaultSuccessfulSubmittedData.copy( - accession = accession, - data = defaultProcessedData.copy( - metadata = defaultProcessedData.metadata.filterKeys { !fields.contains(it) }, - ), - ) - - fun withWrongTypeForFields(accession: Accession = DefaultFiles.firstAccession) = - defaultSuccessfulSubmittedData.copy( - accession = accession, - data = defaultProcessedData.copy( - metadata = defaultProcessedData.metadata + mapOf( - "region" to IntNode(5), - "age" to TextNode("not a number"), - ), - ), - ) - - fun withWrongDateFormat(accession: Accession = DefaultFiles.firstAccession) = defaultSuccessfulSubmittedData.copy( + fun withUnknownMetadataField(accession: Accession, fields: List) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( - metadata = defaultProcessedData.metadata + mapOf( - "date" to TextNode("1.2.2021"), - ), + metadata = defaultProcessedData.metadata + fields.map { it to TextNode("value for $it") }, ), ) - fun withWrongPangoLineageFormat(accession: Accession = DefaultFiles.firstAccession) = - defaultSuccessfulSubmittedData.copy( - accession = accession, - data = defaultProcessedData.copy( - metadata = defaultProcessedData.metadata + mapOf( - "pangoLineage" to TextNode("A.5.invalid"), - ), - ), - ) - - fun withMissingSegmentInUnalignedNucleotideSequences( - accession: Accession = DefaultFiles.firstAccession, - segment: SegmentName, - ) = defaultSuccessfulSubmittedData.copy( + fun withMissingRequiredField(accession: Accession, fields: List) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( - unalignedNucleotideSequences = defaultProcessedData.unalignedNucleotideSequences - segment, + metadata = defaultProcessedData.metadata.filterKeys { !fields.contains(it) }, ), ) - fun withMissingSegmentInAlignedNucleotideSequences( - accession: Accession = DefaultFiles.firstAccession, - segment: SegmentName, - ) = defaultSuccessfulSubmittedData.copy( + fun withWrongTypeForFields(accession: Accession) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( - alignedNucleotideSequences = defaultProcessedData.alignedNucleotideSequences - segment, + metadata = defaultProcessedData.metadata + mapOf( + "region" to IntNode(5), + "age" to TextNode("not a number"), + ), ), ) - fun withUnknownSegmentInAlignedNucleotideSequences( - accession: Accession = DefaultFiles.firstAccession, - segment: SegmentName, - ) = defaultSuccessfulSubmittedData.copy( + fun withWrongDateFormat(accession: Accession) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( - alignedNucleotideSequences = defaultProcessedData.alignedNucleotideSequences + (segment to "NNNN"), + metadata = defaultProcessedData.metadata + mapOf( + "date" to TextNode("1.2.2021"), + ), ), ) - fun withUnknownSegmentInUnalignedNucleotideSequences( - accession: Accession = DefaultFiles.firstAccession, - segment: SegmentName, - ) = defaultSuccessfulSubmittedData.copy( + fun withWrongPangoLineageFormat(accession: Accession) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( - unalignedNucleotideSequences = defaultProcessedData.unalignedNucleotideSequences + (segment to "NNNN"), + metadata = defaultProcessedData.metadata + mapOf( + "pangoLineage" to TextNode("A.5.invalid"), + ), ), ) - fun withUnknownSegmentInNucleotideInsertions( - accession: Accession = DefaultFiles.firstAccession, - segment: SegmentName, - ) = defaultSuccessfulSubmittedData.copy( - accession = accession, - data = defaultProcessedData.copy( - nucleotideInsertions = defaultProcessedData.nucleotideInsertions + ( - segment to listOf( - Insertion( - 123, - "ACTG", + fun withMissingSegmentInUnalignedNucleotideSequences(accession: Accession, segment: SegmentName) = + defaultSuccessfulSubmittedData.copy( + accession = accession, + data = defaultProcessedData.copy( + unalignedNucleotideSequences = defaultProcessedData.unalignedNucleotideSequences - segment, + ), + ) + + fun withMissingSegmentInAlignedNucleotideSequences(accession: Accession, segment: SegmentName) = + defaultSuccessfulSubmittedData.copy( + accession = accession, + data = defaultProcessedData.copy( + alignedNucleotideSequences = defaultProcessedData.alignedNucleotideSequences - segment, + ), + ) + + fun withUnknownSegmentInAlignedNucleotideSequences(accession: Accession, segment: SegmentName) = + defaultSuccessfulSubmittedData.copy( + accession = accession, + data = defaultProcessedData.copy( + alignedNucleotideSequences = defaultProcessedData.alignedNucleotideSequences + (segment to "NNNN"), + ), + ) + + fun withUnknownSegmentInUnalignedNucleotideSequences(accession: Accession, segment: SegmentName) = + defaultSuccessfulSubmittedData.copy( + accession = accession, + data = defaultProcessedData.copy( + unalignedNucleotideSequences = defaultProcessedData.unalignedNucleotideSequences + (segment to "NNNN"), + ), + ) + + fun withUnknownSegmentInNucleotideInsertions(accession: Accession, segment: SegmentName) = + defaultSuccessfulSubmittedData.copy( + accession = accession, + data = defaultProcessedData.copy( + nucleotideInsertions = defaultProcessedData.nucleotideInsertions + ( + segment to listOf( + Insertion( + 123, + "ACTG", + ), + ) ), - ) - ), - ), - ) + ), + ) fun withAlignedNucleotideSequenceOfWrongLength( - accession: Accession = DefaultFiles.firstAccession, + accession: Accession, segment: SegmentName, length: Int = 123, ): SubmittedProcessedData { @@ -277,7 +255,7 @@ object PreparedProcessedData { } fun withAlignedNucleotideSequenceWithWrongSymbols( - accession: Accession = DefaultFiles.firstAccession, + accession: Accession, segment: SegmentName, ): SubmittedProcessedData { val alignedNucleotideSequences = defaultProcessedData.alignedNucleotideSequences.toMutableMap() @@ -290,7 +268,7 @@ object PreparedProcessedData { } fun withUnalignedNucleotideSequenceWithWrongSymbols( - accession: Accession = DefaultFiles.firstAccession, + accession: Accession, segment: SegmentName, ): SubmittedProcessedData { val unalignedNucleotideSequences = defaultProcessedData.unalignedNucleotideSequences.toMutableMap() @@ -302,10 +280,7 @@ object PreparedProcessedData { ) } - fun withNucleotideInsertionsWithWrongSymbols( - accession: Accession = DefaultFiles.firstAccession, - segment: SegmentName, - ): SubmittedProcessedData { + fun withNucleotideInsertionsWithWrongSymbols(accession: Accession, segment: SegmentName): SubmittedProcessedData { val nucleotideInsertions = defaultProcessedData.nucleotideInsertions.toMutableMap() nucleotideInsertions[segment] = listOf(Insertion(123, "ÄÖ")) @@ -315,7 +290,7 @@ object PreparedProcessedData { ) } - fun withMissingGeneInAlignedAminoAcidSequences(accession: Accession = DefaultFiles.firstAccession, gene: GeneName) = + fun withMissingGeneInAlignedAminoAcidSequences(accession: Accession, gene: GeneName) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( @@ -323,7 +298,7 @@ object PreparedProcessedData { ), ) - fun withUnknownGeneInAlignedAminoAcidSequences(accession: Accession = DefaultFiles.firstAccession, gene: GeneName) = + fun withUnknownGeneInAlignedAminoAcidSequences(accession: Accession, gene: GeneName) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( @@ -331,7 +306,7 @@ object PreparedProcessedData { ), ) - fun withUnknownGeneInAminoAcidInsertions(accession: Accession = DefaultFiles.firstAccession, gene: GeneName) = + fun withUnknownGeneInAminoAcidInsertions(accession: Accession, gene: GeneName) = defaultSuccessfulSubmittedData.copy( accession = accession, data = defaultProcessedData.copy( @@ -347,7 +322,7 @@ object PreparedProcessedData { ) fun withAminoAcidSequenceOfWrongLength( - accession: Accession = DefaultFiles.firstAccession, + accession: Accession, gene: SegmentName, length: Int = 123, ): SubmittedProcessedData { @@ -360,10 +335,7 @@ object PreparedProcessedData { ) } - fun withAminoAcidSequenceWithWrongSymbols( - accession: Accession = DefaultFiles.firstAccession, - gene: SegmentName, - ): SubmittedProcessedData { + fun withAminoAcidSequenceWithWrongSymbols(accession: Accession, gene: SegmentName): SubmittedProcessedData { val aminoAcidSequence = defaultProcessedData.alignedAminoAcidSequences.toMutableMap() aminoAcidSequence[gene] = "ÄÖ" + aminoAcidSequence[gene]!!.substring(2) @@ -373,10 +345,7 @@ object PreparedProcessedData { ) } - fun withAminoAcidInsertionsWithWrongSymbols( - accession: Accession = DefaultFiles.firstAccession, - gene: SegmentName, - ): SubmittedProcessedData { + fun withAminoAcidInsertionsWithWrongSymbols(accession: Accession, gene: SegmentName): SubmittedProcessedData { val aminoAcidInsertions = defaultProcessedData.aminoAcidInsertions.toMutableMap() aminoAcidInsertions[gene] = listOf(Insertion(123, "ÄÖ")) @@ -386,7 +355,7 @@ object PreparedProcessedData { ) } - fun withErrors(accession: Accession = DefaultFiles.firstAccession) = defaultSuccessfulSubmittedData.copy( + fun withErrors(accession: Accession) = defaultSuccessfulSubmittedData.copy( accession = accession, errors = listOf( PreprocessingAnnotation( @@ -410,7 +379,7 @@ object PreparedProcessedData { ), ) - fun withWarnings(accession: Accession = DefaultFiles.firstAccession) = defaultSuccessfulSubmittedData.copy( + fun withWarnings(accession: Accession) = defaultSuccessfulSubmittedData.copy( accession = accession, warnings = listOf( PreprocessingAnnotation( diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt index 38373bc3ab..87918a42f2 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt @@ -39,7 +39,7 @@ class ReviseEndpointTest( fun `GIVEN invalid authorization token THEN returns 401 Unauthorized`() { expectUnauthorizedResponse(isModifyingRequest = true) { client.reviseSequenceEntries( - DefaultFiles.revisedMetadataFile, + DefaultFiles.dummyRevisedMetadataFile, DefaultFiles.sequencesFile, jwt = it, ) @@ -49,7 +49,7 @@ class ReviseEndpointTest( @Test fun `WHEN submitting on behalf of a non-existing group THEN expect that the group is not found`() { client.submit( - DefaultFiles.revisedMetadataFile, + DefaultFiles.dummyRevisedMetadataFile, DefaultFiles.sequencesFile, groupName = "nonExistingGroup", ) @@ -63,7 +63,7 @@ class ReviseEndpointTest( val otherUser = "otherUser" client.submit( - DefaultFiles.revisedMetadataFile, + DefaultFiles.dummyRevisedMetadataFile, DefaultFiles.sequencesFile, jwt = generateJwtFor(otherUser), ) @@ -82,22 +82,22 @@ class ReviseEndpointTest( @Test fun `GIVEN entries with status 'APPROVED_FOR_RELEASE' THEN there is a revised version and returns HeaderIds`() { - convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE) + val accessions = convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE).map { it.accession } client.reviseSequenceEntries( - DefaultFiles.revisedMetadataFile, + DefaultFiles.getRevisedMetadataFile(accessions), DefaultFiles.sequencesFile, ) .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(DefaultFiles.NUMBER_OF_SEQUENCES)) .andExpect(jsonPath("\$[0].submissionId").value("custom0")) - .andExpect(jsonPath("\$[0].accession").value(DefaultFiles.firstAccession)) + .andExpect(jsonPath("\$[0].accession").value(accessions.first())) .andExpect(jsonPath("\$[0].version").value(2)) - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 2) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 2) .assertStatusIs(RECEIVED) - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(APPROVED_FOR_RELEASE) val result = client.extractUnprocessedData(DefaultFiles.NUMBER_OF_SEQUENCES) @@ -108,7 +108,7 @@ class ReviseEndpointTest( responseBody, hasItem( UnprocessedData( - accession = DefaultFiles.firstAccession, + accession = accessions.first(), version = 2, data = defaultOriginalData, ), @@ -118,7 +118,9 @@ class ReviseEndpointTest( @Test fun `WHEN submitting revised data with non-existing accessions THEN throws an unprocessableEntity error`() { - convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE) + val accessions = convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE).map { + it.accession + } client.reviseSequenceEntries( SubmitFiles.revisedMetadataFileWith( @@ -126,7 +128,7 @@ class ReviseEndpointTest( """ accession submissionId firstColumn 123 someHeader_main someValue - 1 someHeader2_main someOtherValue + ${accessions.first()} someHeader2_main someOtherValue """.trimIndent(), ), SubmitFiles.sequenceFileWith(), @@ -141,10 +143,12 @@ class ReviseEndpointTest( @Test fun `WHEN submitting revised data for wrong organism THEN throws an unprocessableEntity error`() { - convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE, organism = DEFAULT_ORGANISM) + val accessions = convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE, organism = DEFAULT_ORGANISM).map { + it.accession + } client.reviseSequenceEntries( - DefaultFiles.revisedMetadataFile, + DefaultFiles.getRevisedMetadataFile(accessions), DefaultFiles.sequencesFileMultiSegmented, organism = OTHER_ORGANISM, ) @@ -159,11 +163,11 @@ class ReviseEndpointTest( @Test fun `WHEN submitting revised data not from the submitter THEN throws forbidden error`() { - convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE) + val accessions = convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE).map { it.accession } val notSubmitter = "notTheSubmitter" client.reviseSequenceEntries( - DefaultFiles.revisedMetadataFile, + DefaultFiles.getRevisedMetadataFile(accessions), DefaultFiles.sequencesFile, jwt = generateJwtFor(notSubmitter), ) @@ -182,19 +186,21 @@ class ReviseEndpointTest( @Test fun `WHEN submitting data with version not 'APPROVED_FOR_RELEASE' THEN throws an unprocessableEntity error`() { - convenienceClient.prepareDataTo(HAS_ERRORS) + val accessions = convenienceClient.prepareDataTo(HAS_ERRORS).map { it.accession } client.reviseSequenceEntries( - DefaultFiles.revisedMetadataFile, + DefaultFiles.getRevisedMetadataFile(accessions), DefaultFiles.sequencesFile, ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( - jsonPath("\$.detail").value( - "Accession versions are in not in one of the states [APPROVED_FOR_RELEASE]: " + - "1.1 - HAS_ERRORS, 10.1 - HAS_ERRORS, 2.1 - HAS_ERRORS, 3.1 - HAS_ERRORS, 4.1 - HAS_ERRORS, " + - "5.1 - HAS_ERRORS, 6.1 - HAS_ERRORS, 7.1 - HAS_ERRORS, 8.1 - HAS_ERRORS, 9.1 - HAS_ERRORS", + jsonPath( + "\$.detail", + containsString( + "Accession versions are in not in one of the states [APPROVED_FOR_RELEASE]: " + + "${accessions.first()}.1 - HAS_ERRORS,", + ), ), ) } @@ -375,20 +381,6 @@ class ReviseEndpointTest( "A row in metadata file contains no accession", ), - Arguments.of( - "metadata file with one row with accession which is not a number", - SubmitFiles.metadataFileWith( - content = """ - accession submissionId firstColumn - abc someHeader someValue - 2 someHeader2 someValue - """.trimIndent(), - ), - SubmitFiles.sequenceFileWith(), - status().isUnprocessableEntity, - "Unprocessable Entity", - "A row in metadata file contains no valid accession: abc", - ), ) } } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/RevokeEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/RevokeEndpointTest.kt index 60d691b9a1..2698f1e7f6 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/RevokeEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/RevokeEndpointTest.kt @@ -11,7 +11,6 @@ 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 -import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.firstAccession import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -36,16 +35,16 @@ class RevokeEndpointTest( @Test fun `GIVEN entries with 'APPROVED_FOR_RELEASE' THEN the status changes to 'AWAITING_APPROVAL_FOR_REVOCATION'`() { - convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease() + val accessions = convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease().map { it.accession } - client.revokeSequenceEntries(DefaultFiles.allAccessions) + client.revokeSequenceEntries(accessions) .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(DefaultFiles.NUMBER_OF_SEQUENCES)) - .andExpect(jsonPath("\$[0].accession").value(firstAccession)) + .andExpect(jsonPath("\$[0].accession").value(accessions.first())) .andExpect(jsonPath("\$[0].version").value(2)) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 2) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 2) .assertStatusIs(AWAITING_APPROVAL_FOR_REVOCATION) } @@ -66,9 +65,10 @@ class RevokeEndpointTest( @Test fun `WHEN revoking sequence entry of other organism THEN throws an unprocessableEntity error`() { - convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease(organism = DEFAULT_ORGANISM) + val accessions = convenienceClient + .prepareDefaultSequenceEntriesToApprovedForRelease(organism = DEFAULT_ORGANISM).map { it.accession } - client.revokeSequenceEntries(listOf(firstAccession), organism = OTHER_ORGANISM) + client.revokeSequenceEntries(listOf(accessions.first()), organism = OTHER_ORGANISM) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( @@ -80,10 +80,10 @@ class RevokeEndpointTest( @Test fun `WHEN revoking sequence entries not from the submitter THEN throws forbidden error`() { - convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease() + val accessions = convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease().map { it.accession } val notSubmitter = "nonExistingUser" - client.revokeSequenceEntries(DefaultFiles.allAccessions.subList(0, 2), jwt = generateJwtFor(notSubmitter)) + client.revokeSequenceEntries(accessions, jwt = generateJwtFor(notSubmitter)) .andExpect(status().isForbidden) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( @@ -93,15 +93,18 @@ class RevokeEndpointTest( @Test fun `WHEN revoking with latest version not 'APPROVED_FOR_RELEASE' THEN throws an unprocessableEntity error`() { - convenienceClient.prepareDefaultSequenceEntriesToHasErrors() + val accessions = convenienceClient.prepareDefaultSequenceEntriesToHasErrors().map { it.accession } - client.revokeSequenceEntries(DefaultFiles.allAccessions.subList(0, 2)) + client.revokeSequenceEntries(accessions) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( - jsonPath("\$.detail").value( - "Accession versions are in not in one of the states [${Status.APPROVED_FOR_RELEASE}]: " + - "1.1 - ${Status.HAS_ERRORS}, 2.1 - ${Status.HAS_ERRORS}", + jsonPath( + "\$.detail", + containsString( + "Accession versions are in not in one of the states [${Status.APPROVED_FOR_RELEASE}]: " + + "${accessions.first()}.1 - ${Status.HAS_ERRORS},", + ), ), ) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SingleSegmentedSubmitEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SingleSegmentedSubmitEndpointTest.kt index d3966bc59f..e3b9b72719 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SingleSegmentedSubmitEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SingleSegmentedSubmitEndpointTest.kt @@ -4,9 +4,9 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.hasEntry import org.junit.jupiter.api.Test +import org.loculus.backend.config.BackendConfig import org.loculus.backend.config.BackendSpringProperty import org.loculus.backend.controller.EndpointTest -import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType.APPLICATION_JSON_VALUE import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -23,6 +23,7 @@ private const val DEFAULT_SEQUENCE_NAME = "main" class SingleSegmentedSubmitEndpointTest( @Autowired val submissionControllerClient: SubmissionControllerClient, @Autowired val convenienceClient: SubmissionConvenienceClient, + @Autowired val backendConfig: BackendConfig, ) { @Test fun `GIVEN valid input data without segment name THEN data is accepted and shows segment name 'main'`() { @@ -47,7 +48,7 @@ class SingleSegmentedSubmitEndpointTest( .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(2)) .andExpect(jsonPath("\$[0].submissionId").value("header1")) - .andExpect(jsonPath("\$[0].accession").value(DefaultFiles.firstAccession)) + .andExpect(jsonPath("\$[0].accession", containsString(backendConfig.accessionPrefix))) .andExpect(jsonPath("\$[0].version").value(1)) val unalignedNucleotideSequences = convenienceClient.extractUnprocessedData()[0] 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 71048b2a5d..fafa456011 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 @@ -162,7 +162,7 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec ): ResultActions = mockMvc.perform( post(addOrganismToPath("/revoke", organism = organism)) .contentType(MediaType.APPLICATION_JSON) - .content("""{"accessions":$listOfSequenceEntriesToRevoke}""") + .content("""{"accessions":${objectMapper.writeValueAsString(listOfSequenceEntriesToRevoke)}}""") .withAuth(jwt), ) 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 f9ddccfa38..e100fb6753 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 @@ -128,8 +128,8 @@ class SubmissionConvenienceClient( return accessionVersions } - fun reviseAndProcessDefaultSequenceEntries() { - reviseDefaultProcessedSequenceEntries() + fun reviseAndProcessDefaultSequenceEntries(accessions: List) { + reviseDefaultProcessedSequenceEntries(accessions) val extractedAccessionVersions = extractUnprocessedData().map { AccessionVersion(it.accession, it.version) } submitProcessedData( extractedAccessionVersions @@ -157,12 +157,6 @@ class SubmissionConvenienceClient( ) = client.extractUnprocessedData(numberOfSequenceEntries, organism) .expectNdjsonAndGetContent() - fun prepareDatabaseWithProcessedData(vararg processedData: SubmittedProcessedData) { - submitDefaultFiles() - extractUnprocessedData() - client.submitProcessedData(*processedData) - } - fun getSequenceEntries( username: String = DEFAULT_USER_NAME, groupsFilter: List? = null, @@ -238,8 +232,8 @@ class SubmissionConvenienceClient( ) } - fun submitDefaultEditedData(userName: String = DEFAULT_USER_NAME) { - DefaultFiles.allAccessions.forEach { accession -> + fun submitDefaultEditedData(accessions: List, userName: String = DEFAULT_USER_NAME) { + accessions.forEach { accession -> client.submitEditedSequenceEntryVersion( UnprocessedData(accession, 1L, defaultOriginalData), jwt = generateJwtFor(userName), @@ -263,9 +257,12 @@ class SubmissionConvenienceClient( .andExpect(status().isNoContent) } - fun reviseDefaultProcessedSequenceEntries(organism: String = DEFAULT_ORGANISM): List { + fun reviseDefaultProcessedSequenceEntries( + accessions: List, + organism: String = DEFAULT_ORGANISM, + ): List { val result = client.reviseSequenceEntries( - DefaultFiles.revisedMetadataFile, + DefaultFiles.getRevisedMetadataFile(accessions), DefaultFiles.sequencesFile, organism = organism, ).andExpect(status().isOk) @@ -274,10 +271,10 @@ class SubmissionConvenienceClient( } fun revokeSequenceEntries( - listOfSequencesToRevoke: List, + listOfAccessionsToRevoke: List, organism: String = DEFAULT_ORGANISM, ): List = - deserializeJsonResponse(client.revokeSequenceEntries(listOfSequencesToRevoke, organism = organism)) + deserializeJsonResponse(client.revokeSequenceEntries(listOfAccessionsToRevoke, organism = organism)) fun confirmRevocation( listOfSequencesToConfirm: List, diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt index c064631c57..c3976c6590 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt @@ -26,65 +26,74 @@ class SubmissionJourneyTest( ) { @Test fun `Submission scenario, from submission, over edit and approval ending in status 'APPROVED_FOR_RELEASE'`() { - convenienceClient.submitDefaultFiles() - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + val accessions = convenienceClient.submitDefaultFiles().map { it.accession } + + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(RECEIVED) convenienceClient.extractUnprocessedData() - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(IN_PROCESSING) convenienceClient.submitProcessedData( - DefaultFiles.allAccessions.map { + accessions.map { PreparedProcessedData.withErrors(accession = it) }, ) - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(HAS_ERRORS) - convenienceClient.submitDefaultEditedData() - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + convenienceClient.submitDefaultEditedData(accessions) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(RECEIVED) convenienceClient.extractUnprocessedData() - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(IN_PROCESSING) convenienceClient.submitProcessedData( - DefaultFiles.allAccessions.map { + accessions.map { PreparedProcessedData.successfullyProcessed(accession = it) }, ) - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(AWAITING_APPROVAL) - convenienceClient.approveProcessedSequenceEntries(DefaultFiles.allAccessions.map { AccessionVersion(it, 1) }) - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 1) + convenienceClient.approveProcessedSequenceEntries( + accessions.map { + AccessionVersion(it, 1) + }, + ) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(APPROVED_FOR_RELEASE) } @Test fun `Revising, from submitting revised data over processing, approving ending in status 'APPROVED_FOR_RELEASE'`() { - convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease() + val accessions = convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease().map { it.accession } - convenienceClient.reviseDefaultProcessedSequenceEntries() - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 2) + convenienceClient.reviseDefaultProcessedSequenceEntries(accessions) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 2) .assertStatusIs(RECEIVED) convenienceClient.extractUnprocessedData() - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 2) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 2) .assertStatusIs(IN_PROCESSING) convenienceClient.submitProcessedData( - DefaultFiles.allAccessions.map { + accessions.map { PreparedProcessedData.successfullyProcessed(accession = it, version = 2) }, ) - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 2) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 2) .assertStatusIs(AWAITING_APPROVAL) - convenienceClient.approveProcessedSequenceEntries(DefaultFiles.allAccessions.map { AccessionVersion(it, 2) }) - convenienceClient.getSequenceEntryOfUser(accession = DefaultFiles.firstAccession, version = 2) + convenienceClient.approveProcessedSequenceEntries( + accessions.map { + AccessionVersion(it, 2) + }, + ) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 2) .assertStatusIs(APPROVED_FOR_RELEASE) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEditedSequenceEntryVersionEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEditedSequenceEntryVersionEndpointTest.kt index e3fcd9c22b..982668dc51 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEditedSequenceEntryVersionEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEditedSequenceEntryVersionEndpointTest.kt @@ -9,7 +9,6 @@ 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.firstAccession import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -32,43 +31,43 @@ class SubmitEditedSequenceEntryVersionEndpointTest( @Test fun `GIVEN a sequence entry has errors WHEN I submit edited data THEN the status changes to RECEIVED`() { - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.withErrors()) + val accessions = convenienceClient.prepareDataTo(Status.HAS_ERRORS).map { it.accession } - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) - val editedData = generateUnprocessedData("1") + val editedData = generateUnprocessedData(accessions.first()) client.submitEditedSequenceEntryVersion(editedData) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.RECEIVED) } @Test fun `GIVEN a sequence entry is processed WHEN I submit edited data THEN the status changes to RECEIVED`() { - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.successfullyProcessed()) + val accessions = convenienceClient.prepareDataTo(Status.AWAITING_APPROVAL).map { it.accession } - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.AWAITING_APPROVAL) - val editedData = generateUnprocessedData("1") + val editedData = generateUnprocessedData(accessions.first()) client.submitEditedSequenceEntryVersion(editedData) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.RECEIVED) } @Test fun `WHEN a version does not exist THEN it returns an unprocessable entity error`() { - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.withErrors()) + val accessions = convenienceClient.prepareDataTo(Status.HAS_ERRORS).map { it.accession } - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) - val editedDataWithNonExistingVersion = generateUnprocessedData(firstAccession, version = 2) + val editedDataWithNonExistingVersion = generateUnprocessedData(accessions.first(), version = 2) val sequenceString = getAccessionVersion(editedDataWithNonExistingVersion) client.submitEditedSequenceEntryVersion(editedDataWithNonExistingVersion) @@ -81,35 +80,35 @@ class SubmitEditedSequenceEntryVersionEndpointTest( @Test fun `WHEN an accession does not exist THEN it returns an unprocessable entity error`() { - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.withErrors()) + val accessions = convenienceClient.prepareDataTo(Status.HAS_ERRORS).map { it.accession } - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) - val editedDataWithNonExistingAccession = generateUnprocessedData("2") - val sequenceString = getAccessionVersion(editedDataWithNonExistingAccession) + val nonExistingAccession = "nonExistingAccession" + + val editedDataWithNonExistingAccession = generateUnprocessedData(nonExistingAccession) client.submitEditedSequenceEntryVersion(editedDataWithNonExistingAccession) .andExpect(status().isUnprocessableEntity) .andExpect( jsonPath("\$.detail").value( - "Accession versions are in not in one of the states " + - "[AWAITING_APPROVAL, HAS_ERRORS]: $sequenceString - IN_PROCESSING", + "Accession versions $nonExistingAccession.1 do not exist", ), ) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) } @Test fun `WHEN submitting data for wrong organism THEN it returns an unprocessable entity error`() { - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.withErrors()) + val accessions = convenienceClient.prepareDataTo(Status.HAS_ERRORS).map { it.accession } - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) - val editedData = generateUnprocessedData(firstAccession) + val editedData = generateUnprocessedData(accessions.first()) client.submitEditedSequenceEntryVersion(editedData, organism = OTHER_ORGANISM) .andExpect(status().isUnprocessableEntity) @@ -120,18 +119,18 @@ class SubmitEditedSequenceEntryVersionEndpointTest( ), ) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) } @Test fun `WHEN a sequence entry does not belong to a user THEN it returns an forbidden error`() { - convenienceClient.prepareDatabaseWithProcessedData(PreparedProcessedData.withErrors()) + val accessions = convenienceClient.prepareDataTo(Status.HAS_ERRORS).map { it.accession } - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) - val editedDataFromWrongSubmitter = generateUnprocessedData(firstAccession) + val editedDataFromWrongSubmitter = generateUnprocessedData(accessions.first()) val nonExistingUser = "whoseNameMayNotBeMentioned" client.submitEditedSequenceEntryVersion(editedDataFromWrongSubmitter, jwt = generateJwtFor(nonExistingUser)) @@ -140,7 +139,7 @@ class SubmitEditedSequenceEntryVersionEndpointTest( jsonPath("\$.detail", containsString("is not a member of the group")), ) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt index 684b6f9779..bdf0ab0fb9 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt @@ -14,6 +14,7 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.loculus.backend.api.DataUseTerms import org.loculus.backend.api.Organism +import org.loculus.backend.config.BackendConfig import org.loculus.backend.controller.DEFAULT_GROUP_NAME import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.EndpointTest @@ -36,6 +37,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @EndpointTest class SubmitEndpointTest( @Autowired val submissionControllerClient: SubmissionControllerClient, + @Autowired val backendConfig: BackendConfig, ) { @Test @@ -98,7 +100,7 @@ class SubmitEndpointTest( .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(NUMBER_OF_SEQUENCES)) .andExpect(jsonPath("\$[0].submissionId").value("custom0")) - .andExpect(jsonPath("\$[0].accession").value(DefaultFiles.firstAccession)) + .andExpect(jsonPath("\$[0].accession", containsString(backendConfig.accessionPrefix))) .andExpect(jsonPath("\$[0].version").value(1)) } @@ -113,7 +115,7 @@ class SubmitEndpointTest( .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(NUMBER_OF_SEQUENCES)) .andExpect(jsonPath("\$[0].submissionId").value("custom0")) - .andExpect(jsonPath("\$[0].accession").value(DefaultFiles.firstAccession)) + .andExpect(jsonPath("\$[0].accession", containsString(backendConfig.accessionPrefix))) .andExpect(jsonPath("\$[0].version").value(1)) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitFiles.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitFiles.kt index 016726a8cc..0a465d6b87 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitFiles.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitFiles.kt @@ -5,6 +5,7 @@ import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream import org.loculus.backend.service.submission.CompressionAlgorithm +import org.loculus.backend.utils.Accession import org.springframework.http.MediaType.TEXT_PLAIN_VALUE import org.springframework.mock.web.MockMultipartFile import org.tukaani.xz.LZMA2Options @@ -21,7 +22,30 @@ private const val DEFAULT_MULTI_SEGMENT_SEQUENCES_FILE_NAME = "sequences_multi_s object SubmitFiles { object DefaultFiles { - val revisedMetadataFile = metadataFileWith(content = getFileContent(REVISED_METADATA_FILE_NAME)) + + val dummyRevisedMetadataFile = metadataFileWith( + content = "accession\tsubmissionId\tfirstColumn\n" + + "someAccession\tsomeHeader\tsomeValue\n" + + "someOtherAccession\tsomeHeader2\tsomeValue2", + ) + + fun getRevisedMetadataFile(accessions: List): MockMultipartFile { + val fileContent = getFileContent(REVISED_METADATA_FILE_NAME) + + val lines = fileContent.trim().split("\n") + val headerLine = lines.removeFirst() + + val revisedLines = lines + .map { it.substringAfter('\t') } + .zip(accessions) + .map { (line, accession) -> "$accession\t$line" } + .toList() + + revisedLines.addFirst(headerLine) + + return metadataFileWith(content = revisedLines.joinToString("\n")) + } + val metadataFiles = CompressionAlgorithm.entries.associateWith { metadataFileWith( content = getFileContent(DEFAULT_METADATA_FILE_NAME), @@ -47,8 +71,6 @@ object SubmitFiles { ) const val NUMBER_OF_SEQUENCES = 10 - val allAccessions = (1L..NUMBER_OF_SEQUENCES).toList().map { it.toString() } - val firstAccession = allAccessions[0] private fun getFileContent(file: String): String { return String( diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitProcessedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitProcessedDataEndpointTest.kt index be3e82ba68..b3c183c117 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitProcessedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitProcessedDataEndpointTest.kt @@ -17,7 +17,6 @@ import org.loculus.backend.controller.assertStatusIs import org.loculus.backend.controller.expectForbiddenResponse import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.jwtForDefaultUser -import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.firstAccession import org.loculus.backend.service.submission.AminoAcidSymbols import org.loculus.backend.service.submission.NucleotideSymbols import org.loculus.backend.utils.Accession @@ -37,7 +36,7 @@ class SubmitProcessedDataEndpointTest( fun `GIVEN invalid authorization token THEN returns 401 Unauthorized`() { expectUnauthorizedResponse(isModifyingRequest = true) { submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(), + PreparedProcessedData.successfullyProcessed("DoesNotMatter"), jwt = it, ) } @@ -47,7 +46,7 @@ class SubmitProcessedDataEndpointTest( fun `GIVEN authorization token with wrong role THEN returns 403 Forbidden`() { expectForbiddenResponse { submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(), + PreparedProcessedData.successfullyProcessed("DoesNotMatter"), jwt = jwtForDefaultUser, ) } @@ -55,22 +54,21 @@ class SubmitProcessedDataEndpointTest( @Test fun `WHEN I submit successfully preprocessed data THEN the sequence entry is in status processed`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(accession = "3"), - PreparedProcessedData.successfullyProcessed(accession = "4"), + PreparedProcessedData.successfullyProcessed(accession = accessions.first()), ) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = "3", version = 1).assertStatusIs( + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1).assertStatusIs( Status.AWAITING_APPROVAL, ) } @Test fun `WHEN I submit data with null as sequences THEN the sequence entry is in status processed`() { - val (accession, version, _) = prepareExtractedSequencesInDatabase()[0] + val (accession, version) = prepareExtractedSequencesInDatabase().first() submissionControllerClient.submitProcessedData( PreparedProcessedData.withNullForSequences(accession = accession, version = version), @@ -83,7 +81,7 @@ class SubmitProcessedDataEndpointTest( @Test fun `WHEN I submit with all valid symbols THEN the sequence entry is in status processed`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } val allNucleotideSymbols = NucleotideSymbols.entries.joinToString("") { it.symbol.toString() } val desiredLength = 49 @@ -95,10 +93,10 @@ class SubmitProcessedDataEndpointTest( } val allAminoAcidSymbols = AminoAcidSymbols.entries.joinToString("") { it.symbol.toString() } - val defaultData = PreparedProcessedData.successfullyProcessed().data + val defaultData = PreparedProcessedData.successfullyProcessed(accessions.first()).data submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(accession = "3").copy( + PreparedProcessedData.successfullyProcessed(accession = accessions.first()).copy( data = defaultData.copy( unalignedNucleotideSequences = mapOf(MAIN_SEGMENT to nucleotideSequenceOfDesiredLength), alignedNucleotideSequences = mapOf(MAIN_SEGMENT to nucleotideSequenceOfDesiredLength), @@ -109,29 +107,31 @@ class SubmitProcessedDataEndpointTest( ) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = "3", version = 1).assertStatusIs( + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1).assertStatusIs( Status.AWAITING_APPROVAL, ) } @Test fun `WHEN I submit preprocessed data without insertions THEN the missing keys of the reference will be added`() { - prepareExtractedSequencesInDatabase(organism = OTHER_ORGANISM) + val accessions = prepareExtractedSequencesInDatabase(organism = OTHER_ORGANISM).map { it.accession } - val dataWithoutInsertions = PreparedProcessedData.successfullyProcessedOtherOrganismData().data.copy( + val dataWithoutInsertions = PreparedProcessedData.successfullyProcessedOtherOrganismData( + accessions.first(), + ).data.copy( nucleotideInsertions = mapOf("notOnlySegment" to listOf(Insertion(1, "A"))), aminoAcidInsertions = emptyMap(), ) submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessedOtherOrganismData(accession = "3").copy( + PreparedProcessedData.successfullyProcessedOtherOrganismData(accession = accessions.first()).copy( data = dataWithoutInsertions, ), organism = OTHER_ORGANISM, ).andExpect(status().isNoContent) convenienceClient.getSequenceEntryOfUser( - accession = "3", + accession = accessions.first(), version = 1, organism = OTHER_ORGANISM, ).assertStatusIs( @@ -139,7 +139,7 @@ class SubmitProcessedDataEndpointTest( ) submissionControllerClient.getSequenceEntryThatHasErrors( - accession = "3", + accession = accessions.first(), version = 1, organism = OTHER_ORGANISM, ) @@ -163,25 +163,25 @@ class SubmitProcessedDataEndpointTest( @Test fun `WHEN I submit single-segment data without insertions THEN the missing keys of the reference will be added`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } - val dataWithoutInsertions = PreparedProcessedData.successfullyProcessed().data.copy( + val dataWithoutInsertions = PreparedProcessedData.successfullyProcessed(accessions.first()).data.copy( nucleotideInsertions = mapOf("main" to listOf(Insertion(1, "A"))), aminoAcidInsertions = emptyMap(), ) submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(accession = "3").copy( + PreparedProcessedData.successfullyProcessed(accession = accessions.first()).copy( data = dataWithoutInsertions, ), ).andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = "3", version = 1).assertStatusIs( + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1).assertStatusIs( Status.AWAITING_APPROVAL, ) submissionControllerClient.getSequenceEntryThatHasErrors( - accession = "3", + accession = accessions.first(), version = 1, ) .andExpect(status().isOk) @@ -197,53 +197,53 @@ class SubmitProcessedDataEndpointTest( @Test fun `WHEN I submit null for a non-required field THEN the sequence entry is in status processed`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } submissionControllerClient.submitProcessedData( - PreparedProcessedData.withNullForFields(fields = listOf("dateSubmitted")), + PreparedProcessedData.withNullForFields(accession = accessions.first(), fields = listOf("dateSubmitted")), ) .andExpect(status().isNoContent) prepareExtractedSequencesInDatabase() - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.AWAITING_APPROVAL) } @Test fun `WHEN I submit data with errors THEN the sequence entry is in status has errors`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } - submissionControllerClient.submitProcessedData(PreparedProcessedData.withErrors(firstAccession)) + submissionControllerClient.submitProcessedData(PreparedProcessedData.withErrors(accessions.first())) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) } @Test fun `GIVEN I submitted invalid data and errors THEN the sequence entry is in status has errors`() { - convenienceClient.submitDefaultFiles() + val accessions = convenienceClient.submitDefaultFiles().map { it.accession } convenienceClient.extractUnprocessedData(1) submissionControllerClient.submitProcessedData( - PreparedProcessedData.withWrongDateFormat().copy( - accession = firstAccession, - errors = PreparedProcessedData.withErrors().errors, + PreparedProcessedData.withWrongDateFormat(accessions.first()).copy( + accession = accessions.first(), + errors = PreparedProcessedData.withErrors(accessions.first()).errors, ), ).andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.HAS_ERRORS) } @Test fun `WHEN I submit data with warnings THEN the sequence entry is in status processed`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } - submissionControllerClient.submitProcessedData(PreparedProcessedData.withWarnings(firstAccession)) + submissionControllerClient.submitProcessedData(PreparedProcessedData.withWarnings(accessions.first())) .andExpect(status().isNoContent) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.AWAITING_APPROVAL) } @@ -252,15 +252,17 @@ class SubmitProcessedDataEndpointTest( fun `GIVEN invalid processed data THEN refuses to update and an error will be thrown`( invalidDataScenario: InvalidDataScenario, ) { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } - submissionControllerClient.submitProcessedData(invalidDataScenario.processedData) + submissionControllerClient.submitProcessedData( + invalidDataScenario.processedDataThatNeedsAValidAccession.copy(accession = accessions.first()), + ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.detail").value(invalidDataScenario.expectedErrorMessage)) val sequenceStatus = convenienceClient.getSequenceEntryOfUser( - accession = invalidDataScenario.processedData.accession, + accession = accessions.first(), version = 1, ) assertThat(sequenceStatus.status, `is`(Status.IN_PROCESSING)) @@ -268,68 +270,71 @@ class SubmitProcessedDataEndpointTest( @Test fun `WHEN I submit data for a non-existent accession THEN refuses update with unprocessable entity`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } - val nonExistentAccesion = "999" + val nonExistentAccession = "999" submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(accession = "1"), - PreparedProcessedData.successfullyProcessed(accession = nonExistentAccesion), + PreparedProcessedData.successfullyProcessed(accession = accessions.first()), + PreparedProcessedData.successfullyProcessed(accession = nonExistentAccession), ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) - .andExpect(jsonPath("\$.detail").value("Accession version $nonExistentAccesion.1 does not exist")) + .andExpect(jsonPath("\$.detail").value("Accession version $nonExistentAccession.1 does not exist")) - convenienceClient.getSequenceEntryOfUser(accession = "1", version = 1).assertStatusIs(Status.IN_PROCESSING) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1).assertStatusIs( + Status.IN_PROCESSING, + ) } @Test fun `WHEN I submit data for a non-existent accession version THEN refuses update with unprocessable entity`() { - prepareExtractedSequencesInDatabase() + val accessions = prepareExtractedSequencesInDatabase().map { it.accession } val nonExistentVersion = 999L submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(accession = firstAccession), - PreparedProcessedData.successfullyProcessed(accession = firstAccession) + PreparedProcessedData.successfullyProcessed(accession = accessions.first()), + PreparedProcessedData.successfullyProcessed(accession = accessions.first()) .copy(version = nonExistentVersion), ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( jsonPath("\$.detail").value( - "Accession version $firstAccession.$nonExistentVersion does not exist", + "Accession version ${accessions.first()}.$nonExistentVersion does not exist", ), ) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessions.first(), version = 1) .assertStatusIs(Status.IN_PROCESSING) } @Test fun `WHEN I submit data for an entry that is not in processing THEN refuses update with unprocessable entity`() { - val accession = prepareUnprocessedSequenceEntry() + val accessionsNotInProcessing = convenienceClient.prepareDataTo(Status.AWAITING_APPROVAL).map { it.accession } + val accessionsInProcessing = convenienceClient.prepareDataTo(Status.IN_PROCESSING).map { it.accession } - val accessionNotInProcessing = "2" - convenienceClient.getSequenceEntryOfUser(accession = accessionNotInProcessing, version = 1) - .assertStatusIs(Status.RECEIVED) + convenienceClient.getSequenceEntryOfUser(accession = accessionsNotInProcessing.first(), version = 1) + .assertStatusIs(Status.AWAITING_APPROVAL) submissionControllerClient.submitProcessedData( - PreparedProcessedData.successfullyProcessed(accession = accession), - PreparedProcessedData.successfullyProcessed(accession = accessionNotInProcessing), + PreparedProcessedData.successfullyProcessed(accession = accessionsInProcessing.first()), + PreparedProcessedData.successfullyProcessed(accession = accessionsNotInProcessing.first()), ) .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( jsonPath("\$.detail").value( - "Accession version $accessionNotInProcessing.1 is in not in state IN_PROCESSING (was RECEIVED)", + "Accession version ${accessionsNotInProcessing.first()}.1 " + + "is in not in state IN_PROCESSING (was AWAITING_APPROVAL)", ), ) - convenienceClient.getSequenceEntryOfUser(accession = firstAccession, version = 1) + convenienceClient.getSequenceEntryOfUser(accession = accessionsInProcessing.first(), version = 1) .assertStatusIs(Status.IN_PROCESSING) - convenienceClient.getSequenceEntryOfUser(accession = accessionNotInProcessing, version = 1) - .assertStatusIs(Status.RECEIVED) + convenienceClient.getSequenceEntryOfUser(accession = accessionsNotInProcessing.first(), version = 1) + .assertStatusIs(Status.AWAITING_APPROVAL) } @Test @@ -339,7 +344,7 @@ class SubmitProcessedDataEndpointTest( submissionControllerClient.submitProcessedDataRaw( """ { - "accession": $accession, + "accession": "$accession", "version": 1, "data": { "noMetadata": null, @@ -367,10 +372,12 @@ class SubmitProcessedDataEndpointTest( .andExpect(status().isUnprocessableEntity) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( - jsonPath("\$.detail").value(containsString("1.1 is for organism dummyOrganism")), + jsonPath("\$.detail") + .value(containsString("$accession.1 is for organism dummyOrganism")), ) .andExpect( - jsonPath("\$.detail").value(containsString("submitted data is for organism otherOrganism")), + jsonPath("\$.detail") + .value(containsString("submitted data is for organism otherOrganism")), ) } @@ -417,7 +424,8 @@ class SubmitProcessedDataEndpointTest( fun provideInvalidMetadataScenarios() = listOf( InvalidDataScenario( name = "data with unknown metadata fields", - processedData = PreparedProcessedData.withUnknownMetadataField( + processedDataThatNeedsAValidAccession = PreparedProcessedData.withUnknownMetadataField( + accession = "DoesNotMatter", fields = listOf( "unknown field 1", "unknown field 2", @@ -427,30 +435,42 @@ class SubmitProcessedDataEndpointTest( ), InvalidDataScenario( name = "data with missing required fields", - processedData = PreparedProcessedData.withMissingRequiredField(fields = listOf("date", "region")), + processedDataThatNeedsAValidAccession = PreparedProcessedData.withMissingRequiredField( + accession = "DoesNotMatter", + fields = listOf("date", "region"), + ), expectedErrorMessage = "Missing the required field 'date'.", ), InvalidDataScenario( name = "data with wrong type for fields", - processedData = PreparedProcessedData.withWrongTypeForFields(), + processedDataThatNeedsAValidAccession = PreparedProcessedData.withWrongTypeForFields( + accession = "DoesNotMatter", + ), expectedErrorMessage = "Expected type 'string' for field 'region', found value '5'.", ), InvalidDataScenario( name = "data with wrong date format", - processedData = PreparedProcessedData.withWrongDateFormat(), + processedDataThatNeedsAValidAccession = PreparedProcessedData.withWrongDateFormat( + accession = "DoesNotMatter", + ), expectedErrorMessage = "Expected type 'date' in format 'yyyy-MM-dd' for field 'date', found value '\"1.2.2021\"'.", ), InvalidDataScenario( name = "data with wrong pango lineage format", - processedData = PreparedProcessedData.withWrongPangoLineageFormat(), + processedDataThatNeedsAValidAccession = PreparedProcessedData.withWrongPangoLineageFormat( + accession = "DoesNotMatter", + ), expectedErrorMessage = "Expected type 'pango_lineage' for field 'pangoLineage', found value '\"A.5.invalid\"'. " + "A pango lineage must be of the form [a-zA-Z]{1,3}(\\.\\d{1,3}){0,3}, e.g. 'XBB' or 'BA.1.5'.", ), InvalidDataScenario( name = "data with explicit null for required field", - processedData = PreparedProcessedData.withNullForFields(fields = listOf("date")), + processedDataThatNeedsAValidAccession = PreparedProcessedData.withNullForFields( + accession = "DoesNotMatter", + fields = listOf("date"), + ), expectedErrorMessage = "Field 'date' is null, but a value is required.", ), ) @@ -459,58 +479,86 @@ class SubmitProcessedDataEndpointTest( fun provideInvalidNucleotideSequenceDataScenarios() = listOf( InvalidDataScenario( name = "data with missing segment in unaligned nucleotide sequences", - processedData = PreparedProcessedData.withMissingSegmentInUnalignedNucleotideSequences( - segment = "main", - ), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withMissingSegmentInUnalignedNucleotideSequences( + accession = "DoesNotMatter", + segment = "main", + ), expectedErrorMessage = "Missing the required segment 'main' in 'unalignedNucleotideSequences'.", ), InvalidDataScenario( name = "data with missing segment in aligned nucleotide sequences", - processedData = PreparedProcessedData.withMissingSegmentInAlignedNucleotideSequences(segment = "main"), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withMissingSegmentInAlignedNucleotideSequences( + accession = "DoesNotMatter", + segment = "main", + ), expectedErrorMessage = "Missing the required segment 'main' in 'alignedNucleotideSequences'.", ), InvalidDataScenario( name = "data with unknown segment in alignedNucleotideSequences", - processedData = PreparedProcessedData.withUnknownSegmentInAlignedNucleotideSequences( - segment = "someOtherSegment", - ), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withUnknownSegmentInAlignedNucleotideSequences( + accession = "DoesNotMatter", + segment = "someOtherSegment", + ), expectedErrorMessage = "Unknown segments in 'alignedNucleotideSequences': someOtherSegment.", ), InvalidDataScenario( name = "data with unknown segment in unalignedNucleotideSequences", - processedData = PreparedProcessedData.withUnknownSegmentInUnalignedNucleotideSequences( - segment = "someOtherSegment", - ), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withUnknownSegmentInUnalignedNucleotideSequences( + accession = "DoesNotMatter", + segment = "someOtherSegment", + ), expectedErrorMessage = "Unknown segments in 'unalignedNucleotideSequences': someOtherSegment.", ), InvalidDataScenario( name = "data with unknown segment in nucleotideInsertions", - processedData = PreparedProcessedData.withUnknownSegmentInNucleotideInsertions( - segment = "someOtherSegment", - ), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withUnknownSegmentInNucleotideInsertions( + accession = "DoesNotMatter", + segment = "someOtherSegment", + ), expectedErrorMessage = "Unknown segments in 'nucleotideInsertions': someOtherSegment.", ), InvalidDataScenario( name = "data with segment in aligned nucleotide sequences of wrong length", - processedData = PreparedProcessedData.withAlignedNucleotideSequenceOfWrongLength(segment = "main"), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withAlignedNucleotideSequenceOfWrongLength( + accession = "DoesNotMatter", + segment = "main", + ), expectedErrorMessage = "The length of 'main' in 'alignedNucleotideSequences' is 123, " + "but it should be 49.", ), InvalidDataScenario( name = "data with segment in aligned nucleotide sequences with wrong symbols", - processedData = PreparedProcessedData.withAlignedNucleotideSequenceWithWrongSymbols(segment = "main"), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withAlignedNucleotideSequenceWithWrongSymbols( + accession = "DoesNotMatter", + segment = "main", + ), expectedErrorMessage = "The sequence of segment 'main' in 'alignedNucleotideSequences' contains " + "invalid symbols: [Ä, Ö].", ), InvalidDataScenario( name = "data with segment in unaligned nucleotide sequences with wrong symbols", - processedData = PreparedProcessedData.withUnalignedNucleotideSequenceWithWrongSymbols(segment = "main"), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withUnalignedNucleotideSequenceWithWrongSymbols( + accession = "DoesNotMatter", + segment = "main", + ), expectedErrorMessage = "The sequence of segment 'main' in 'unalignedNucleotideSequences' contains " + "invalid symbols: [Ä, Ö].", ), InvalidDataScenario( name = "data with segment in nucleotide insertions with wrong symbols", - processedData = PreparedProcessedData.withNucleotideInsertionsWithWrongSymbols(segment = "main"), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withNucleotideInsertionsWithWrongSymbols( + accession = "DoesNotMatter", + segment = "main", + ), expectedErrorMessage = "The insertion 123:ÄÖ of segment 'main' in 'nucleotideInsertions' contains " + "invalid symbols: [Ä, Ö].", ), @@ -520,40 +568,58 @@ class SubmitProcessedDataEndpointTest( fun provideInvalidAminoAcidSequenceDataScenarios() = listOf( InvalidDataScenario( name = "data with missing gene in alignedAminoAcidSequences", - processedData = PreparedProcessedData.withMissingGeneInAlignedAminoAcidSequences( - gene = SOME_SHORT_GENE, - ), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withMissingGeneInAlignedAminoAcidSequences( + accession = "DoesNotMatter", + gene = SOME_SHORT_GENE, + ), expectedErrorMessage = "Missing the required gene 'someShortGene'.", ), InvalidDataScenario( name = "data with unknown gene in alignedAminoAcidSequences", - processedData = PreparedProcessedData.withUnknownGeneInAlignedAminoAcidSequences( - gene = "someOtherGene", - ), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withUnknownGeneInAlignedAminoAcidSequences( + accession = "DoesNotMatter", + gene = "someOtherGene", + ), expectedErrorMessage = "Unknown genes in 'alignedAminoAcidSequences': someOtherGene.", ), InvalidDataScenario( name = "data with unknown gene in aminoAcidInsertions", - processedData = PreparedProcessedData.withUnknownGeneInAminoAcidInsertions( - gene = "someOtherGene", - ), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withUnknownGeneInAminoAcidInsertions( + accession = "DoesNotMatter", + gene = "someOtherGene", + ), expectedErrorMessage = "Unknown genes in 'aminoAcidInsertions': someOtherGene.", ), InvalidDataScenario( name = "data with gene in alignedAminoAcidSequences of wrong length", - processedData = PreparedProcessedData.withAminoAcidSequenceOfWrongLength(gene = SOME_SHORT_GENE), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withAminoAcidSequenceOfWrongLength( + accession = "DoesNotMatter", + gene = SOME_SHORT_GENE, + ), expectedErrorMessage = "The length of 'someShortGene' in 'alignedAminoAcidSequences' is 123, " + "but it should be 4.", ), InvalidDataScenario( name = "data with gene in alignedAminoAcidSequences with wrong symbols", - processedData = PreparedProcessedData.withAminoAcidSequenceWithWrongSymbols(gene = SOME_SHORT_GENE), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withAminoAcidSequenceWithWrongSymbols( + accession = "DoesNotMatter", + gene = SOME_SHORT_GENE, + ), expectedErrorMessage = "The gene 'someShortGene' in 'alignedAminoAcidSequences' contains " + "invalid symbols: [Ä, Ö].", ), InvalidDataScenario( name = "data with segment in amino acid insertions with wrong symbols", - processedData = PreparedProcessedData.withAminoAcidInsertionsWithWrongSymbols(gene = SOME_SHORT_GENE), + processedDataThatNeedsAValidAccession = PreparedProcessedData + .withAminoAcidInsertionsWithWrongSymbols( + accession = "DoesNotMatter", + gene = SOME_SHORT_GENE, + ), expectedErrorMessage = "An insertion of gene 'someShortGene' in 'aminoAcidInsertions' contains " + "invalid symbols: [Ä, Ö].", ), @@ -563,7 +629,7 @@ class SubmitProcessedDataEndpointTest( data class InvalidDataScenario( val name: String, - val processedData: SubmittedProcessedData, + val processedDataThatNeedsAValidAccession: SubmittedProcessedData, val expectedErrorMessage: String, ) { override fun toString(): String { diff --git a/backend/src/test/kotlin/org/loculus/backend/service/GenerateAccessionFromNumberServiceTest.kt b/backend/src/test/kotlin/org/loculus/backend/service/GenerateAccessionFromNumberServiceTest.kt new file mode 100644 index 0000000000..a0fb8f6858 --- /dev/null +++ b/backend/src/test/kotlin/org/loculus/backend/service/GenerateAccessionFromNumberServiceTest.kt @@ -0,0 +1,72 @@ +package org.loculus.backend.service + +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.jupiter.api.Test +import org.loculus.backend.config.BackendConfig +import java.lang.Math.random +import kotlin.math.pow + +const val PREFIX = "LOC_" + +class GenerateAccessionFromNumberServiceTest { + private val accessionFromNumberService = GenerateAccessionFromNumberService( + BackendConfig(accessionPrefix = PREFIX, instances = emptyMap()), + ) + + @Test + fun `GIVEN sequence numbers and prefix THEN returns custom ids that are padded to 6 digits`() { + val sequenceNumbers: List = listOf( + 1, + 1 * 34.0.pow(1.0).toLong() + 3, + 3 * 34.0.pow(2.0).toLong() + 2 * 34.0.pow(1.0).toLong() + 1, + ) + + val expectedAccessions = listOf( + "${PREFIX}000001Y", + "${PREFIX}000013T", + "${PREFIX}000321Q", + ) + + val result = sequenceNumbers.map { accessionFromNumberService.generateCustomId(it) } + + assertThat(result, `is`(expectedAccessions)) + } + + @Test + fun `GIVEN large sequence numbers THEN returns longer custom ids `() { + val sequenceNumber: Long = 34.0.pow(6.0).toLong() + 1 + + assertThat(accessionFromNumberService.generateCustomId(sequenceNumber), `is`("${PREFIX}1000001W")) + } + + @Test + fun `GIVEN a valid accession THEN the validation succeeds`() { + val sequenceNumber = (random() * 1e6).toLong() + + val accession = accessionFromNumberService.generateCustomId(sequenceNumber) + + val isValidAccession = accessionFromNumberService.validateAccession(accession) + assertThat(isValidAccession, `is`(true)) + } + + @Test + fun `GIVEN an accession without prefix THEN the validation fails`() { + val sequenceNumber = (random() * 1e6).toLong() + + val accessionWithoutPrefix = accessionFromNumberService + .generateCustomId(sequenceNumber) + .removePrefix(PREFIX) + + val isValidAccession = accessionFromNumberService.validateAccession(accessionWithoutPrefix) + assertThat(isValidAccession, `is`(false)) + } + + @Test + fun `GIVEN an invalid accession THEN the validation fails`() { + val invalidAccession = PREFIX + "Not a valid accession" + + val isValidAccession = accessionFromNumberService.validateAccession(invalidAccession) + assertThat(isValidAccession, `is`(false)) + } +} diff --git a/backend/src/test/kotlin/org/loculus/backend/service/submission/EmptyProcessedDataProviderTest.kt b/backend/src/test/kotlin/org/loculus/backend/service/submission/EmptyProcessedDataProviderTest.kt index 82a634d580..2142272390 100644 --- a/backend/src/test/kotlin/org/loculus/backend/service/submission/EmptyProcessedDataProviderTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/service/submission/EmptyProcessedDataProviderTest.kt @@ -24,7 +24,8 @@ private const val SECOND_AMINO_ACID_SEQUENCE = "secondAminoAcidSequence" class EmptyProcessedDataProviderTest { private val underTest = EmptyProcessedDataProvider( BackendConfig( - mapOf( + accessionPrefix = "LOC_", + instances = mapOf( DEFAULT_ORGANISM to InstanceConfig( schema = Schema( FIRST_NUCLEOTIDE_SEQUENCE, diff --git a/backend/src/test/resources/backend_config.json b/backend/src/test/resources/backend_config.json index 3770333cae..1c3c365193 100644 --- a/backend/src/test/resources/backend_config.json +++ b/backend/src/test/resources/backend_config.json @@ -1,4 +1,5 @@ { + "accessionPrefix": "LOC_", "instances": { "dummyOrganism": { "referenceGenomes": { diff --git a/backend/src/test/resources/backend_config_single_segment.json b/backend/src/test/resources/backend_config_single_segment.json index 42fc93aa27..4ca39d199b 100644 --- a/backend/src/test/resources/backend_config_single_segment.json +++ b/backend/src/test/resources/backend_config_single_segment.json @@ -1,4 +1,5 @@ { + "accessionPrefix" : "LOC_", "instances": { "dummyOrganism": { "referenceGenomes": { diff --git a/website/tests/e2e.fixture.ts b/website/tests/e2e.fixture.ts index 91b30a6b78..ac8b1db9f1 100644 --- a/website/tests/e2e.fixture.ts +++ b/website/tests/e2e.fixture.ts @@ -16,6 +16,7 @@ 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 { AccessionTransformer } from './util/accessionTransformer.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'; @@ -55,30 +56,33 @@ export const e2eLogger = winston.createLogger({ transports: [new winston.transports.Console()], }); +export const accessionPrefix = 'LOC_'; +export const accessionTransformer = new AccessionTransformer(accessionPrefix); + export const backendClient = BackendClient.create(backendUrl, e2eLogger); export const groupManagementClient = GroupManagementClient.create(backendUrl, e2eLogger); export const testSequenceEntry = { - name: '1.1', - accession: '1', + name: `${accessionTransformer.generateCustomId(1)}.1`, + accession: accessionTransformer.generateCustomId(1), version: 1, unaligned: 'A'.repeat(123), orf1a: 'QRFEINSA', }; export const revokedSequenceEntry = { - accession: '11', + accession: accessionTransformer.generateCustomId(11), version: 1, }; export const revocationSequenceEntry = { - accession: '11', + accession: accessionTransformer.generateCustomId(11), version: 2, }; export const deprecatedSequenceEntry = { - accession: '21', + accession: accessionTransformer.generateCustomId(21), version: 1, }; export const revisedSequenceEntry = { - accession: '21', + accession: accessionTransformer.generateCustomId(21), version: 2, }; @@ -229,7 +233,7 @@ export async function createTestGroupIfNotExistent(token: string, groupName: str const groupDoesAlreadyExist = isErrorFromAlias(groupManagementApi, 'createGroup', error) && error.response.status === 409; if (!groupDoesAlreadyExist) { - throw error; + throw new Error(`Could not create Groups. Backend up and running? Error: ${JSON.stringify(error)}`); } } } diff --git a/website/tests/playwrightSetup.ts b/website/tests/playwrightSetup.ts index 34d182ffbe..2478a8120d 100644 --- a/website/tests/playwrightSetup.ts +++ b/website/tests/playwrightSetup.ts @@ -2,6 +2,7 @@ import isEqual from 'lodash/isEqual.js'; import sortBy from 'lodash/sortBy.js'; import { + accessionPrefix, createTestGroupIfNotExistent, DEFAULT_GROUP_NAME, e2eLogger, @@ -10,6 +11,7 @@ import { testUser, testUserPassword, } from './e2e.fixture.ts'; +import { AccessionTransformer } from './util/accessionTransformer.ts'; import { prepareDataToBe } from './util/prepareDataToBe.ts'; import { LapisClient } from '../src/services/lapisClient.ts'; import { ACCESSION_FIELD, IS_REVOCATION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD } from '../src/settings.ts'; @@ -92,12 +94,26 @@ function waitSeconds(seconds: number) { } async function checkLapisState(lapisClient: LapisClient): Promise { + const accessionTransformer = new AccessionTransformer(accessionPrefix); + const numberOfSequencesInLapisResult = await lapisClient.call('aggregated', {}); if (numberOfSequencesInLapisResult._unsafeUnwrap().data[0].count === 0) { return LapisStateBeforeTests.NoSequencesInLapis; } + const [singleLatestVersionAccession, revisedAndRevokedAccession, revisedAccession] = + accessionTransformer.generateCustomIds([1, 11, 21]); + + e2eLogger.info( + 'Checking LAPIS for sequences with accessions: ' + + singleLatestVersionAccession + + ', ' + + revisedAndRevokedAccession + + ', ' + + revisedAccession, + ); + const fields = [ACCESSION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD, IS_REVOCATION_FIELD]; const [ shouldBeLatestVersionResult, @@ -105,9 +121,9 @@ async function checkLapisState(lapisClient: LapisClient): Promise 0); + + while (base34Digits.length < 6) { + base34Digits.push('0'); + } + + const serialAccessionPart: string = base34Digits.reverse().join(''); + return this.accessionPrefix + serialAccessionPart + this.generateCheckCharacter(serialAccessionPart); + } + + public validateAccession(accession: string): boolean { + if (!accession.startsWith(this.accessionPrefix)) { + return false; + } + return this.validateCheckCharacter(accession.substring(this.accessionPrefix.length)); + } + + private generateCheckCharacter(input: string): string { + let factor: number = 2; + let sum: number = 0; + const numberOfValidInputCharacters: number = AccessionTransformer.CODE_POINTS.length; + + for (let i: number = input.length - 1; i >= 0; i--) { + let addend: number = factor * this.getCodePointFromCharacter(input[i]); + + factor = factor === 2 ? 1 : 2; + + addend = Math.floor(addend / numberOfValidInputCharacters) + (addend % numberOfValidInputCharacters); + sum += addend; + } + + const remainder: number = sum % numberOfValidInputCharacters; + const checkCodePoint: number = (numberOfValidInputCharacters - remainder) % numberOfValidInputCharacters; + return AccessionTransformer.CODE_POINTS.charAt(checkCodePoint); + } + + private validateCheckCharacter(input: string): boolean { + let factor: number = 1; + let sum: number = 0; + const numberOfValidInputCharacters: number = AccessionTransformer.CODE_POINTS.length; + + for (let i: number = input.length - 1; i >= 0; i--) { + const codePoint: number = this.getCodePointFromCharacter(input[i]); + let addend: number = factor * codePoint; + + factor = factor === 2 ? 1 : 2; + + addend = Math.floor(addend / numberOfValidInputCharacters) + (addend % numberOfValidInputCharacters); + sum += addend; + } + + const remainder: number = sum % numberOfValidInputCharacters; + return remainder === 0; + } + + private getCodePointFromCharacter(character: string): number { + return AccessionTransformer.CODE_POINTS.indexOf(character); + } +}