Class DatasetsWebServiceTest

java.lang.Object
org.glassfish.jersey.test.JerseyTest
ubic.gemma.rest.util.BaseJerseyTest5
ubic.gemma.rest.DatasetsWebServiceTest
All Implemented Interfaces:
org.springframework.beans.factory.Aware, org.springframework.context.ApplicationContextAware

@ContextConfiguration @TestExecutionListeners(value=org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener.class, mergeMode=MERGE_WITH_DEFAULTS) public class DatasetsWebServiceTest extends BaseJerseyTest5
  • Constructor Details

    • DatasetsWebServiceTest

      public DatasetsWebServiceTest()
  • Method Details

    • testSetSampleLibraryStrategyReachesTheService

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testSetSampleLibraryStrategyReachesTheService()
    • testOmittedBioAssayIdsMeansEverySample

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testOmittedBioAssayIdsMeansEverySample()
      Omitting bioAssayIds applies to every sample, rather than to none.
    • testForeignBioAssayIdIsRefused

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testForeignBioAssayIdIsRefused()
      A sample id belonging to another dataset is refused before anything is written. Without this the route would happily set a column on a sample the caller never named a dataset for.
    • testUnknownLibraryStrategyBecomesA400

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testUnknownLibraryStrategyBecomesA400()
      An unknown vocabulary value becomes a 400 that names what IS accepted, not a 500.
    • testEmptyBodyIsRefused

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testEmptyBodyIsRefused()
      A body naming no field at all is a caller bug, not a silent no-op.
    • testClearingRequiresTheClearFlag

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testClearingRequiresTheClearFlag()
      Clearing needs the clear flag: a null field alone is "absent", so a caller that omits a field can never wipe a column by accident.
    • testSampleMetadataRouteIsCuratorOrAdmin

      @Test public void testSampleMetadataRouteIsCuratorOrAdmin() throws NoSuchMethodException
      The route is curator-or-admin.

      🛑 Asserted on the ANNOTATION, not by calling the route as a GROUP_USER and expecting 403. @PreAuthorize is enforced by MethodSecurityConfig in gemma-core, which this test context does not import — no test in this class wires method security, so a call as an ordinary user returns 200 here and would prove nothing either way. Reflection still fails if someone drops or weakens the annotation, which is the regression worth catching at this level.

      Throws:
      NoSuchMethodException
    • setUpMocks

      @BeforeEach public void setUpMocks() throws TimeoutException
      Throws:
      TimeoutException
    • resetMocks

      @AfterEach public void resetMocks()
    • testCommitRejectsUngroundedTerm

      @Test public void testCommitRejectsUngroundedTerm()
      A tag whose label doesn't match its URI is rejected with a structured, per-slot 400.
    • testCommitRejectsUngroundedDesignStatementTerm

      @Test public void testCommitRejectsUngroundedDesignStatementTerm()
      A design-section factor-value statement with an ungrounded term is rejected, located in the design tree.
    • testCommitRefusesDeletedIdThatIsNotOnThatFactorValue

      @Test public void testCommitRefusesDeletedIdThatIsNotOnThatFactorValue()
      A deletedIds entry naming a statement that is not on that factor value is refused.

      The delete is a suppression of the carry-forward, so an id that is not among the factor value's current statements suppresses nothing and the commit answered 200 with deleted: 0 — which reads exactly like a delete that worked. A caller recorded eight such deletions against eid 6146 on 2026-09-01; the ids were real CHARACTERISTIC rows on no factor value of that dataset.

    • testCommitAcceptsDeletedIdThatIsOnThatFactorValue

      @Test public void testCommitAcceptsDeletedIdThatIsOnThatFactorValue()
      The id that IS on the factor value is accepted, so the refusal is about membership, not about deleting.
    • testCommitRefusesFactorDeletedIdThatIsNotOnThisDataset

      @Test public void testCommitRefusesFactorDeletedIdThatIsNotOnThisDataset()
      A factor deletedIds naming a factor of another dataset is refused the same way.
    • testPreflightAcceptsTheBaselineRelevanceHint

      @Test public void testPreflightAcceptsTheBaselineRelevanceHint()
      The baseline-relevance hint is accepted on the write side and reaches the design mapper.

      It was published on ExperimentalFactor and ExperimentalFactorValueObject and absent from FactorCommit, so the curation UI's "Tick to override: no baseline" checkbox and its reason box had nowhere to land: every preflight carrying them came back 400 Unrecognized field "baselineRelevance" … not marked as ignorable — a readable, renderable, unsettable field (cab, 2026-09-04, GSE32473 factor 13474).

    • testPreflightAcceptsAnUnfamiliarBaselineRelevanceValue

      @Test public void testPreflightAcceptsAnUnfamiliarBaselineRelevanceValue()
      A word outside the three in use round-trips instead of 400ing. The hint's vocabulary has moved once already, and a closed allowableValues would make the next word a schema change and a deploy (cab, 2026-09-04: "don't lock us into any kind of enums").
    • testCommitAndPreflightAcceptOnBehalfOf

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testCommitAndPreflightAcceptOnBehalfOf()
      The commit and the preflight accept onBehalfOf. Both refused it with 400 Unknown query parameter while sign required it, so a relay sending the parameter uniformly across the chain — which is the sane client — was broken on two of its three calls (uib, 2026-09-05, on GSE7866).

      It is not decoration on the commit: actingAs is what attributes the restore point to the curator instead of the courier, and what keeps requireNoForeignCurationLock from refusing a commit relayed for the very curator who holds the lock.

    • testAnAgentRecordingADecisionMustNameThePersonItActsFor

      @Test @WithMockUser(username="gemmaAgent", authorities="GROUP_AGENT") public void testAnAgentRecordingADecisionMustNameThePersonItActsFor(@Autowired CurationDecisionService curationDecisionService)
      An agent recording a decision must name the person it acts for, and never its own account. Paul, 2026-09-15, of CURATION_DECISION.DECIDED_BY: "it should be the person". All 1,074 rows on gemd named gemmaAgent.
    • testAnAgentDecisionNamesThePersonAndRecordsTheAgentAsJudge

      @Test @WithMockUser(username="gemmaAgent", authorities="GROUP_AGENT") public void testAnAgentDecisionNamesThePersonAndRecordsTheAgentAsJudge(@Autowired CurationDecisionService curationDecisionService)
      The agent's part is recorded in the judge kind, which defaults to AGENT for an agent caller.
    • testFactorValueSamplesCanBeNamedByBioMaterialId

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testFactorValueSamplesCanBeNamedByBioMaterialId()
      A factor value can name its samples by BioMaterial id.

      The old contract took names only, and the names it took were the GSM accession and the bioassay short name — never the bioMaterialName that GET /datasets/{id}/design hands back, so a client echoing what it read was refused (uib, GSE7866). Worse, a single-cell sub-bioassay has NO accession by construction — 15 of 15 null on GSE124952 subset 68405 — so on those datasets no string could name a sample at all. Paul: "Gemma must do it by its own ID for the sample … the id is the primary key."

    • testBioMaterialIdBindsOnBothCommitSections

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testBioMaterialIdBindsOnBothCommitSections()
      The field binds at all — a payload carrying it is not rejected as an unknown property.
    • testPreflightRejectsUngroundedTerm

      @Test public void testPreflightRejectsUngroundedTerm()
      Preflight enforces the same gate, so a client catches the failure on the dry run.
    • testGetDatasets

      @Test public void testGetDatasets()
    • testGetDatasetsWithQuery

      @Test public void testGetDatasetsWithQuery() throws SearchException, TimeoutException
      Throws:
      SearchException
      TimeoutException
    • testGetDatasetsWithQueryAndSort

      @Test public void testGetDatasetsWithQueryAndSort() throws SearchException, TimeoutException
      Throws:
      SearchException
      TimeoutException
    • testGetDatasetsWithEmptyQuery

      @Test public void testGetDatasetsWithEmptyQuery()
    • testGetDatasetsWhenSliceHasNoLimit

      @Test public void testGetDatasetsWhenSliceHasNoLimit()
    • testGetDatasetsOrderedByGeeqScore

      @Test public void testGetDatasetsOrderedByGeeqScore()
    • testGetDatasetsWhenInferenceTimeoutThenProduce503ServiceUnavailable

      @Test public void testGetDatasetsWhenInferenceTimeoutThenProduce503ServiceUnavailable() throws TimeoutException
      Throws:
      TimeoutException
    • testGetDatasetsPlatformsUsageStatistics

      @Test public void testGetDatasetsPlatformsUsageStatistics() throws TimeoutException
      Throws:
      TimeoutException
    • testGetDatasetsAnnotationsWithRetainMentionedTerms

      @Test public void testGetDatasetsAnnotationsWithRetainMentionedTerms() throws TimeoutException
      Throws:
      TimeoutException
    • testGetDatasetsAnnotations

      @Test public void testGetDatasetsAnnotations() throws TimeoutException
      Throws:
      TimeoutException
    • testGetDatasetsAnnotationWhenLimitExceedHardCap

      @Test public void testGetDatasetsAnnotationWhenLimitExceedHardCap()
    • testGetDatasetsAnnotationsWhenMaxFrequencyIsSuppliedLimitMustUseMaximum

      @Test public void testGetDatasetsAnnotationsWhenMaxFrequencyIsSuppliedLimitMustUseMaximum() throws TimeoutException
      Throws:
      TimeoutException
    • testGetDatasetsAnnotationsWithLimitIsSupplied

      @Test public void testGetDatasetsAnnotationsWithLimitIsSupplied() throws TimeoutException
      Throws:
      TimeoutException
    • testGetDatasetsAnnotationsForUncategorizedTerms

      @Test public void testGetDatasetsAnnotationsForUncategorizedTerms() throws TimeoutException
      Throws:
      TimeoutException
    • testGetDatasetsCategories

      @Test public void testGetDatasetsCategories()
    • testGetDatasetQuantitationTypes

      @Test public void testGetDatasetQuantitationTypes()
    • testGetDatasetProcessedExpression

    • testGetDatasetProcessedExpressionWhenNoProcessedVectorsExist

      @Test public void testGetDatasetProcessedExpressionWhenNoProcessedVectorsExist()
    • testGetDatasetRawExpression

    • testGetDatasetRawExpressionByQuantitationTypeWhenQtIsNotFromTheDataset

      @Test public void testGetDatasetRawExpressionByQuantitationTypeWhenQtIsNotFromTheDataset()
    • testGetDatasetRawExpressionByQuantitationType

      @Test public void testGetDatasetRawExpressionByQuantitationType() throws IOException, URISyntaxException, InterruptedException, TimeoutException
      Throws:
      IOException
      URISyntaxException
      InterruptedException
      TimeoutException
    • testGetBlacklistedDatasets

      @Test public void testGetBlacklistedDatasets()
    • testGetDatasetAnnotations

      @Test public void testGetDatasetAnnotations()
    • testGetDatasetAnnotationsExcludingFreeText

      @Test public void testGetDatasetAnnotationsExcludingFreeText()
    • testAddDatasetAnnotationTag

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testAddDatasetAnnotationTag()
    • testAddDatasetAnnotationTagDuplicateReturnsConflict

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testAddDatasetAnnotationTagDuplicateReturnsConflict()
    • testAddDatasetAnnotationTagAcceptsAndDropsAnnotationSetId

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testAddDatasetAnnotationTagAcceptsAndDropsAnnotationSetId()
    • testRemoveDatasetAnnotationTag

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testRemoveDatasetAnnotationTag()
    • testRemoveDatasetAnnotationTagNotFound

      @Test @WithMockUser(authorities="GROUP_CURATOR") public void testRemoveDatasetAnnotationTagNotFound()
    • testUpdateDatasetAnnotations

      @Test @WithMockUser public void testUpdateDatasetAnnotations()
    • testUpdateDatasetAnnotationsRejectsUngroundedStatementTerm

      @Test @WithMockUser public void testUpdateDatasetAnnotationsRejectsUngroundedStatementTerm()
      A tag write reaches the four statement URI columns, so its new terms are ground-checked the way the curation commit's are: an object URI that resolves nowhere is a 400 and nothing is written. Before this, the identical payload was a 400 on PUT /datasets/{id}/curation and a 200 here — which is how OBJECT_URI = 'Prethalamus' reached three factor values in production.
    • testUpdateDatasetAnnotationsDoesNotRecheckAnAlreadyStoredTag

      @Test @WithMockUser public void testUpdateDatasetAnnotationsDoesNotRecheckAnAlreadyStoredTag()
      🛑 A tag that is already stored is NOT re-checked, even when the stored URI is malformed. A set-replace carries the whole desired set, so a client editing one tag echoes back every other one as served — including the 105 colon-form URIs the read serves verbatim (they are not in the migration shim). Checking the whole desired set would answer 400 to the unrelated edit. Same rule as the commit's carry-forward items; identity is CharacteristicUtils.sameTag, the predicate the service diffs on.
    • testAddDatasetAnnotationRejectsUngroundedTerm

      @Test @WithMockUser public void testAddDatasetAnnotationRejectsUngroundedTerm()
      The single-tag add is ground-checked too: it is always an add, so every term it carries is new.
    • testAddSampleCharacteristicRejectsUngroundedTerm

      @Test @WithMockUser public void testAddSampleCharacteristicRejectsUngroundedTerm()
      The sample-level add goes through the same gate — sample characteristics carry statements as well.
    • testUpdateDatasetAnnotationsAcceptsEmptyListAsClear

      @Test @WithMockUser public void testUpdateDatasetAnnotationsAcceptsEmptyListAsClear()
    • testUpdateDatasetAnnotationsMissingBody

      @Test @WithMockUser public void testUpdateDatasetAnnotationsMissingBody()
    • testUpdateDatasetAnnotationsRejectsBlankCategory

      @Test @WithMockUser public void testUpdateDatasetAnnotationsRejectsBlankCategory()
    • testUpdateDatasetAnnotationsRejectsBlankValue

      @Test @WithMockUser public void testUpdateDatasetAnnotationsRejectsBlankValue()
    • testUpdateDatasetAnnotationsConstructsStatementWhenPredicateAndObjectPresent

      @Test @WithMockUser public void testUpdateDatasetAnnotationsConstructsStatementWhenPredicateAndObjectPresent()
    • testUpdateDatasetAnnotationsConstructsStatementWithSecondPair

      @Test @WithMockUser public void testUpdateDatasetAnnotationsConstructsStatementWithSecondPair()
    • testUpdateDatasetAnnotationsRefusesAPredicateWithoutAnObject

      @Test @WithMockUser public void testUpdateDatasetAnnotationsRefusesAPredicateWithoutAnObject()
    • testUpdateDatasetAnnotationsStaysPlainCharacteristicWhenNoStatementFields

      @Test @WithMockUser public void testUpdateDatasetAnnotationsStaysPlainCharacteristicWhenNoStatementFields()
    • testUpdateDatasetAnnotationsCarriesSupportingEvidence

      @Test @WithMockUser public void testUpdateDatasetAnnotationsCarriesSupportingEvidence()
    • testUpdateDatasetAnnotationsNoEvidenceLeavesSupportingEvidenceNull

      @Test @WithMockUser public void testUpdateDatasetAnnotationsNoEvidenceLeavesSupportingEvidenceNull()
    • testGetDatasetsDifferentialAnalysisResultsExpressionForGene

      @Test public void testGetDatasetsDifferentialAnalysisResultsExpressionForGene()
    • testGetDatasetsDifferentialAnalysisResultsExpressionForGeneInTaxa

      @Test public void testGetDatasetsDifferentialAnalysisResultsExpressionForGeneInTaxa()
    • testGetDatasetsAnalysisResultSets

      @Test public void testGetDatasetsAnalysisResultSets()
    • testRefreshDataset

      @Test @WithMockUser public void testRefreshDataset()
    • testRefreshDatasetWithDefaultsDoesNotEvictCaches

      @Test @WithMockUser public void testRefreshDatasetWithDefaultsDoesNotEvictCaches()
    • testRefreshDatasetVectorsOnly

      @Test @WithMockUser public void testRefreshDatasetVectorsOnly()
    • testRefreshDatasetReportsOnly

      @Test @WithMockUser public void testRefreshDatasetReportsOnly()
    • testRefreshDatasetNotFound

      @Test @WithMockUser public void testRefreshDatasetNotFound()
    • testGetDatasetSingleCellData

    • testGetDatasetSingleCellDataWhenCacheIsCold

      @Test public void testGetDatasetSingleCellDataWhenCacheIsCold() throws Exception
      Cold cache: the tabular single-cell data is generated ONCE, streaming to the caller and populating the cache file in the same pass — never by racing a fire-and-forget background build against an in-band stream of the same data, which on this endpoint meant two concurrent full scans of the largest payloads in the system.
      Throws:
      Exception
    • testGetDatasetCellTypeAssignmentAsJsonDoesNotLoadCellIds

      @Test public void testGetDatasetCellTypeAssignmentAsJsonDoesNotLoadCellIds()
      The JSON output of cellTypeAssignment does not read cell ids, so it must not load them. On MSSM_Cohort (3.7 million cells, 2026-09-18) that load was about 4 s of an 880-byte response.
    • testGetDatasetCellLevelCharacteristicsAsJsonDoesNotLoadCellIds

      @Test public void testGetDatasetCellLevelCharacteristicsAsJsonDoesNotLoadCellIds()
      As for cellTypeAssignment: the JSON output of cellLevelCharacteristics does not read cell ids.
    • testGetDatasetSingleCellDataAsDownload

      @Test public void testGetDatasetSingleCellDataAsDownload() throws InterruptedException, TimeoutException, URISyntaxException, IOException
      Throws:
      InterruptedException
      TimeoutException
      URISyntaxException
      IOException
    • testGetDatasetSingleCellDataAsMex

    • testGetDatasetSubSetGroups

      @Test public void testGetDatasetSubSetGroups()
    • testGetDatasetSubSetGroup

      @Test public void testGetDatasetSubSetGroup()
    • testGetDatasetSubSets

      @Test public void testGetDatasetSubSets()
    • testGetDatasetSubSet

      @Test public void testGetDatasetSubSet()
    • testGetDatasetSamplesIsCompressed

      @Test public void testGetDatasetSamplesIsCompressed()
      🛑 The route was not compressed, and that — not any single field — was the size of the problem.

      GET /datasets/3937/samples sent 5,381,688 bytes for 278 samples with no Content-Encoding, and the same body gzips to 144,390 — a 37x reduction with no client change and no field removed, larger than every trim in this commit put together. Compression here is opt-in per endpoint via @GZIP, so a heavy new route is uncompressed by default and nothing says so; the annotation is the whole gate. Measured on production, b5c6747f68.

    • testGetDatasetSamplesCanExcludeStatements

      @Test public void testGetDatasetSamplesCanExcludeStatements()
      sample.statements is 21.5% of the samples response and carries the same rows as sample.characteristics plus a predicate and object, so a client that renders only subjects can decline it. Excluded means absent, not empty — see BioMaterialValueObject.getStatements().
    • testGetDatasetSamplesHonoursALimitByPaginating

      @Test public void testGetDatasetSamplesHonoursALimitByPaginating()
      🛑 limit was bound by the route and then used only on the cursor branch, so a client asking for a page got the whole dataset instead, with nothing in the body saying so.

      Measured on production (gemma2, dataset 7332 = GSE2109, 2158 samples): ?limit=20 returned all 2158 assays, byte-identical to the no-parameter call. Silently truncating the legacy body to 20 would have lost 2138 rows just as invisibly — it has no totalElements and no nextCursor in which to declare it — so limit now selects cursor mode, whose wrapper can. A bare limit is also the only way into cursor mode from a standing start: neither listing has an offset mode, so nothing they can return without a cursor carries a nextCursor to continue from.

    • testGetDatasetSubSetSamplesHonoursALimitByPaginating

      @Test public void testGetDatasetSubSetSamplesHonoursALimitByPaginating()
      The sibling subset listing has the same shape and takes a bare limit the same way.
    • testGetDatasetSamplesRejectsALimitOnTheQuantitationTypeNarrowedListing

      @Test public void testGetDatasetSamplesRejectsALimitOnTheQuantitationTypeNarrowedListing()
      The QT-narrowed sample listings sort by assay name and restrict to a BioAssayDimension, neither of which an id-only cursor can express, so they stay unpaginated — and a limit there is refused rather than dropped.
    • testGetDatasetSamplesRejectsAnUnsupportedExclusion

      @Test public void testGetDatasetSamplesRejectsAnUnsupportedExclusion()
      An exclusion the route does not offer is a 400, not a silently ignored parameter.
    • testGetDatasetSamplesDoesNotComputePredictedOutliersByDefault

      @Test public void testGetDatasetSamplesDoesNotComputePredictedOutliersByDefault()
      The predicted-outlier flag costs the dataset's whole N×N sample-correlation matrix to compute, which is why it is opt-in: the cost is set by the correlation analysis, not the page size, and on the largest datasets it exceeds the request timeout. The assertion is that the detection service is not consulted at all by default — a cheaper-but-still-called path would still load the matrix.
    • testGetDatasetSubSetSamples

      @Test public void testGetDatasetSubSetSamples()
    • testPreviewDatasetDesignChangeNoOp

      @Test public void testPreviewDatasetDesignChangeNoOp()
    • testPreviewDatasetDesignChangeWithBlockers

      @Test public void testPreviewDatasetDesignChangeWithBlockers()
    • testPreviewDatasetDesignChangeReportsDeletions

      @Test public void testPreviewDatasetDesignChangeReportsDeletions()
    • testPreviewDatasetDesignChangeWithEmptyBodyIs400

      @Test public void testPreviewDatasetDesignChangeWithEmptyBodyIs400()
    • testPreviewDatasetDesignChangeWithUnknownDatasetIs404

      @Test public void testPreviewDatasetDesignChangeWithUnknownDatasetIs404()
    • testGetDatasetAuditEvents

      @Test public void testGetDatasetAuditEvents()
    • testGetDatasetAuditEventsWhenEmpty

      @Test public void testGetDatasetAuditEventsWhenEmpty()
    • testGetDatasetAuditEventsWithUnknownDatasetIs404

      @Test public void testGetDatasetAuditEventsWithUnknownDatasetIs404()
    • testGetDatasetAuditEventsCompactFalseReturnsFullList

      @Test public void testGetDatasetAuditEventsCompactFalseReturnsFullList()
    • testGetDatasetAuditEventsCompactCollapsesConsecutiveSameTypeAndPerformer

      @Test public void testGetDatasetAuditEventsCompactCollapsesConsecutiveSameTypeAndPerformer()
    • testGetDatasetAuditEventsCompactAlternatingTypesProducesAllSolo

      @Test public void testGetDatasetAuditEventsCompactAlternatingTypesProducesAllSolo()
    • testGetDatasetAuditEventsCompactMixedAABA

      @Test public void testGetDatasetAuditEventsCompactMixedAABA()
    • testGetDatasetAuditEventsCompactOnEmptyIsEmpty

      @Test public void testGetDatasetAuditEventsCompactOnEmptyIsEmpty()
    • testGetDatasetAuditEventsExcludeEmptyDropsTypelessBlankEvents

      @Test public void testGetDatasetAuditEventsExcludeEmptyDropsTypelessBlankEvents()
    • testGetDatasetAuditEventsExcludeEmptyComposesWithCompact

      @Test public void testGetDatasetAuditEventsExcludeEmptyComposesWithCompact()
    • testGetDatasetCurationDetails

      @Test @WithMockUser public void testGetDatasetCurationDetails()
    • testGetDatasetCurationDetailsExposesCurationNoteForAdmin

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetCurationDetailsExposesCurationNoteForAdmin()
    • testGetDatasetCurationDetailsReportsCurationPendingWhileLocked

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetCurationDetailsReportsCurationPendingWhileLocked()
      curationPending is the curation lock's unexpired lease and nothing else: it must read true while a lock is held, and the response must still name nobody. The holder is served by /datasets/{id}/curation/lock, which is authenticated; this field is readable by anyone who can read the dataset.
    • testPatchQuantitationTypeCorrectsTheScale

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPatchQuantitationTypeCorrectsTheScale()
      The case this was expanded for: a pre-2018 Affymetrix `rma value` recorded as LINEAR when RMA output is log2. The correction changes the record, not the numbers, so it must reach quantitationTypeService.update and say what moved.
    • testPatchQuantitationTypeDoesNotTouchPreference

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPatchQuantitationTypeDoesNotTouchPreference()
      Preference did not change, so no preferred-data event may be emitted for it. Routing a descriptive patch through updateQuantitationType would emit one, because that path reads the preferred flag and cannot tell "already preferred" from "just became preferred".
    • testPatchQuantitationTypeRefusesRepresentation

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPatchQuantitationTypeRefusesRepresentation()
      representation describes the stored values rather than how to read them, so patching it would misdescribe the vectors. Refused with a reason rather than silently ignored.
    • testPatchQuantitationTypeRejectsANoOp

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPatchQuantitationTypeRejectsANoOp()
      A patch that asks for what the record already says changes nothing, so it is a bad request rather than a 200 with an audit event nobody can interpret.
    • testGetDatasetCurationDetailsHidesCurationPendingFromNonAdmins

      @Test @WithMockUser public void testGetDatasetCurationDetailsHidesCurationPendingFromNonAdmins()
      A non-administrator does not learn that curation is under way. The lock is consulted either way -- what changes is whether the answer is kept -- so the field reads null rather than false, which would assert something untrue. Null is how curationNote already behaves for a non-administrator.
    • testGetDatasetCurationDetailsReportsNoCurationPendingWhenUnlocked

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetCurationDetailsReportsNoCurationPendingWhenUnlocked()
      A free dataset reads false, not null: the GET always consults the lock, so the reader can tell "nobody is curating" from "this path did not look".
    • testGetDatasetCurationDetailsWithUnknownDatasetIs404

      @Test @WithMockUser public void testGetDatasetCurationDetailsWithUnknownDatasetIs404()
    • testUpdateDatasetCurationDetailsSetsTroubled

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsSetsTroubled()
    • testUpdateDatasetCurationDetailsClearsTroubled

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsClearsTroubled()
    • testUpdateDatasetCurationDetailsSkipsNoOpTroubled

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsSkipsNoOpTroubled()
    • testUpdateDatasetCurationDetailsSetsNeedsAttention

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsSetsNeedsAttention()
    • testUpdateDatasetCurationDetailsClearsNeedsAttention

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsClearsNeedsAttention()
    • testUpdateDatasetCurationDetailsUpdatesCurationNote

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsUpdatesCurationNote()
    • testUpdateDatasetCurationDetailsAppliesMultipleChanges

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsAppliesMultipleChanges()
    • testUpdateDatasetCurationDetailsWithEmptyBodyIs400

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsWithEmptyBodyIs400()
    • testUpdateDatasetCurationDetailsWithUnknownDatasetIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetCurationDetailsWithUnknownDatasetIs404()
    • testUpdateDatasetPermissionsMakesPublic

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetPermissionsMakesPublic()
    • testUpdateDatasetPermissionsMakePublicOnAlreadyPublicRecordsNoEvent

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetPermissionsMakePublicOnAlreadyPublicRecordsNoEvent()
    • testUpdateDatasetPermissionsMakesPrivate

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetPermissionsMakesPrivate()
    • testUpdateDatasetPermissionsReturnsCurrentStateWhenIsPublicOmitted

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetPermissionsReturnsCurrentStateWhenIsPublicOmitted()
    • testMakeDatasetPublicFlipsTheAcl

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testMakeDatasetPublicFlipsTheAcl()
    • testMakeDatasetPublicOnAlreadyPublicIsNoOpNoEvent

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testMakeDatasetPublicOnAlreadyPublicIsNoOpNoEvent()
    • testMakeDatasetPrivateRecordsMakePrivateEvent

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testMakeDatasetPrivateRecordsMakePrivateEvent()
    • testMakeDatasetPrivateOnAlreadyPrivateIsNoOpNoEvent

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testMakeDatasetPrivateOnAlreadyPrivateIsNoOpNoEvent()
    • testUpdateDatasetPermissionsWithEmptyBodyIs400

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetPermissionsWithEmptyBodyIs400()
    • testUpdateDatasetPermissionsWithUnknownDatasetIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testUpdateDatasetPermissionsWithUnknownDatasetIs404()
    • testDeleteDatasetRawDataHappyPath

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testDeleteDatasetRawDataHappyPath()
    • testDeleteDatasetRawDataWithoutConfirmIs400

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testDeleteDatasetRawDataWithoutConfirmIs400()
    • testDeleteDatasetProcessedDataHappyPath

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testDeleteDatasetProcessedDataHappyPath()
    • testDeleteDatasetProcessedDataWithoutConfirmIs400

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testDeleteDatasetProcessedDataWithoutConfirmIs400()
    • testGetDatasetPipelineStatusReturnsAllStepsWithNotRunOrNotApplicable

      @Test @WithMockUser public void testGetDatasetPipelineStatusReturnsAllStepsWithNotRunOrNotApplicable()
    • testGetDatasetPipelineStatusMissingValueApplicableForTwoColor

      @Test @WithMockUser public void testGetDatasetPipelineStatusMissingValueApplicableForTwoColor()
    • testGetDatasetPipelineStatusPreprocessOk

      @Test @WithMockUser public void testGetDatasetPipelineStatusPreprocessOk()
    • testGetDatasetPipelineStatusCarriesFilterAttrition

      @Test @WithMockUser public void testGetDatasetPipelineStatusCarriesFilterAttrition() throws Exception
      The sample-correlation step carries the filter attrition recorded when the matrix was computed.

      The JSON is produced the way AuditedAspect produces it -- through the polymorphic AuditEventPayload type, so the @type discriminator is present. That is the half that breaks silently: a reader whose mapper has not been told about the subtype cannot resolve the type id, and the endpoint would answer with the step present and the attrition quietly absent.

      Throws:
      Exception
    • testGetDatasetPipelineStatusCarriesProcessedVectorDetails

      @Test @WithMockUser public void testGetDatasetPipelineStatusCarriesProcessedVectorDetails() throws Exception
      The preprocess step carries what the processed-vector creation did to the data. This payload has been written since the Phase C audit migration and nothing served it, which is why the diagnostics footer had nothing to show for "normalization".

      It also guards the reader against a trap the single-payload version had: the status read walks the latest event of every step, so a mapper registered for only one payload record cannot resolve any of the others and turns each into a parse failure -- silently, since the step still reports.

      Throws:
      Exception
    • testGetDatasetPipelineStatusWithoutFilterAttritionStillReportsTheStep

      @Test @WithMockUser public void testGetDatasetPipelineStatusWithoutFilterAttritionStillReportsTheStep()
      Every correlation matrix computed before the payload existed carries no payload at all. That reads as absent, not as an error and not as "nothing was filtered" -- and the rest of the step still reports.
    • testGetDatasetPipelineStatusSurvivesAnUnparseableAuditPayload

      @Test @WithMockUser public void testGetDatasetPipelineStatusSurvivesAnUnparseableAuditPayload()
      A payload that does not parse must not take the status read down with it.
    • testGetDatasetPipelineStatusPcaFailed

      @Test @WithMockUser public void testGetDatasetPipelineStatusPcaFailed()
    • testGetDatasetPipelineStatusPicksLatestEvent

      @Test @WithMockUser public void testGetDatasetPipelineStatusPicksLatestEvent()
    • testPipelineStatusDeaGoesStaleWhenTheDesignChangedAfterIt

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPipelineStatusDeaGoesStaleWhenTheDesignChangedAfterIt()
      A DEA that succeeded and then had the design change under it is still there, and its own event still says it succeeded — but it no longer describes the design it was computed from. `stale` is that state.

      🛑 Not `notRun`: the analysis was not deleted. A design change that invalidates an analysis deletes it, and the step then reads `notRun` with nothing left to describe. This is the surviving case.

    • testPipelineStatusDeaStaysOkWhenTheDesignChangedBeforeIt

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPipelineStatusDeaStaysOkWhenTheDesignChangedBeforeIt()
      A design change BEFORE the analysis is the normal order: the DEA already reflects it.
    • testPipelineStatusEveryDataDerivedStepGoesStaleOnADesignChange

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPipelineStatusEveryDataDerivedStepGoesStaleOnADesignChange()
      Every step but batchInfo is computed from the samples and the design, so a design change makes all of them stale -- not just the DEA. Paul, 2026-08-27: *"everything you mention needs to be redone if sample-sets and experimental designs are changed, but we want to do it later"*. This test used to assert the opposite; DesignChangeEvent is emitted only for a real change (the no-op branch suppresses it), so a relabel does not reach here.
    • testPipelineStatusGoesStaleWhenAnOutlierWasFlaggedAfterTheRun

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPipelineStatusGoesStaleWhenAnOutlierWasFlaggedAfterTheRun()
      Flagging an outlier changes the analyzed sample set and no longer reprocesses the dataset inline, so the SampleRemovalEvent it records is what marks the computed results as owed a re-run.
    • testPipelineStatusBatchInfoIsNeverStale

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPipelineStatusBatchInfoIsNeverStale()
      batchInfo comes from scan dates and file headers. Neither a design edit nor an outlier flag touches those, so it must not be swept up when the rule widened to every other step.
    • testPipelineStatusAFailedDeaStaysFailedAfterADesignChange

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testPipelineStatusAFailedDeaStaysFailedAfterADesignChange()
      A DEA that FAILED and was then followed by a design change stays `failed`. Re-running is the move either way, and `stale` would hide that the last attempt did not succeed.
    • testStaleStepsListsADatasetWhoseRunPredatesTheDesignChange

      @Test @WithMockUser public void testStaleStepsListsADatasetWhoseRunPredatesTheDesignChange()
      The corpus-wide read of the same stale rule the per-dataset route applies: which datasets owe pipeline work, and which steps. A PCA that succeeded and then had the design change under it is the canonical row.
    • testStaleStepsOmitsADatasetWhoseRunPostdatesTheDesignChange

      @Test @WithMockUser public void testStaleStepsOmitsADatasetWhoseRunPostdatesTheDesignChange()
      The disconfirming half: the same two events in the other order. The design change happened BEFORE the run, so the run already reflects it and nothing is owed. Without this the route could report every dataset that has ever had a design change and still look right.
    • testStaleStepsDropsACandidateTheCallerCannotRead

      @Test @WithMockUser public void testStaleStepsDropsACandidateTheCallerCannotRead()
      The candidate ids come from an audit query that has no ACL clause, so what keeps a private dataset out of the response is the ACL-filtered load it is passed through. Here the load returns nothing for the candidate: the row must not appear, and the audit fan-out must not even be attempted for it.
    • testGetDatasetPipelineStatusIncludesConvenienceFields

      @Test @WithMockUser public void testGetDatasetPipelineStatusIncludesConvenienceFields()
    • testGetDatasetPipelineStatusAdminSeesCurationNote

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetPipelineStatusAdminSeesCurationNote()
    • testGetDatasetPipelineStatusNonAdminDoesNotSeeCurationNote

      @Test @WithMockUser public void testGetDatasetPipelineStatusNonAdminDoesNotSeeCurationNote()
    • testGetDatasetPipelineStatusWithUnknownDatasetIs404

      @Test @WithMockUser public void testGetDatasetPipelineStatusWithUnknownDatasetIs404()
    • testGetDatasetGeeq

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetGeeq()
    • testGetDatasetGeeqWithoutEvent

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetGeeqWithoutEvent()
    • testGetDatasetGeeqWhenNotComputedIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetGeeqWhenNotComputedIs404()
    • testGetDatasetGeeqWithUnknownDatasetIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testGetDatasetGeeqWithUnknownDatasetIs404()
    • testRecomputeDatasetGeeqDefaultModeIsAll

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRecomputeDatasetGeeqDefaultModeIsAll()
    • testRecomputeDatasetGeeqWithSpecificMode

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRecomputeDatasetGeeqWithSpecificMode()
    • testRecomputeDatasetGeeqIncludesLastComputed

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRecomputeDatasetGeeqIncludesLastComputed()
    • testRecomputeDatasetGeeqWithUnknownDatasetIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRecomputeDatasetGeeqWithUnknownDatasetIs404()
    • testRunDatasetPreprocess

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetPreprocess()
    • testRunDatasetPreprocessWithUnknownDatasetIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetPreprocessWithUnknownDatasetIs404()
    • testRunDatasetDiagnosticsSetsFlag

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetDiagnosticsSetsFlag()
    • testRunDatasetBatchInformationFetch

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetBatchInformationFetch()
    • testRunDatasetGeeqWithModeBatch

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetGeeqWithModeBatch()
    • testRunDatasetSwitchPlatformWithTargetShortName

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetSwitchPlatformWithTargetShortName()
    • testRunDatasetSwitchPlatformWithoutBodyAutoDetectsMerged

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetSwitchPlatformWithoutBodyAutoDetectsMerged()
    • testRunDatasetSwitchPlatformUnknownShortNameIs400

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetSwitchPlatformUnknownShortNameIs400()
    • testRunDatasetDifferentialAnalysisWithoutBodyUsesAllNonBatchFactors

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetDifferentialAnalysisWithoutBodyUsesAllNonBatchFactors()
    • testRunDatasetDifferentialAnalysisWithoutDesignIs400

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetDifferentialAnalysisWithoutDesignIs400()
    • testRunDatasetDifferentialAnalysisRejectsUnknownFactorId

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetDifferentialAnalysisRejectsUnknownFactorId()
    • testRunDatasetDifferentialAnalysisRejectsSubsetFactorInFactorIds

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRunDatasetDifferentialAnalysisRejectsSubsetFactorInFactorIds()
    • testRedoDatasetDifferentialAnalysis

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRedoDatasetDifferentialAnalysis()
    • testRedoDatasetDifferentialAnalysisWithUnknownAnalysisIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRedoDatasetDifferentialAnalysisWithUnknownAnalysisIs404()
    • testRemoveDatasetDifferentialAnalysis

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRemoveDatasetDifferentialAnalysis()
    • testRemoveDatasetDifferentialAnalysisWithUnknownAnalysisIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRemoveDatasetDifferentialAnalysisWithUnknownAnalysisIs404()
    • testGetDatasetDesignJson

      @Test public void testGetDatasetDesignJson()
    • testGetDatasetDesignDefaultIsJson

      @Test public void testGetDatasetDesignDefaultIsJson()
    • testGetDatasetDesignJsonReturnsNotFoundWhenDesignMissing

      @Test public void testGetDatasetDesignJsonReturnsNotFoundWhenDesignMissing()
    • testGetDatasetDesignJsonWithUnknownDatasetIs404

      @Test public void testGetDatasetDesignJsonWithUnknownDatasetIs404()
    • testReplaceDatasetDesignHappyPath

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testReplaceDatasetDesignHappyPath()
    • testReplaceDatasetDesignReturnsBadRequestOnBlockers

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testReplaceDatasetDesignReturnsBadRequestOnBlockers()
    • testReplaceDatasetDesignReturns409WhenForceRequired

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testReplaceDatasetDesignReturns409WhenForceRequired()
    • testReplaceDatasetDesignWithForceAppliesEvenWithCascade

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testReplaceDatasetDesignWithForceAppliesEvenWithCascade()
    • testReplaceDatasetDesignWithEmptyBodyIs400

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testReplaceDatasetDesignWithEmptyBodyIs400()
    • testReplaceDatasetDesignWithUnknownDatasetIs404

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testReplaceDatasetDesignWithUnknownDatasetIs404()
    • testGetDatasetSampleCorrelation

      @Test public void testGetDatasetSampleCorrelation()
    • testGetDatasetSampleCorrelationWhenNoneIs404

      @Test public void testGetDatasetSampleCorrelationWhenNoneIs404()
    • testSampleCorrelationValuesAreRoundedAndKeepNaN

      @Test public void testSampleCorrelationValuesAreRoundedAndKeepNaN()
      Three decimals, and a masked cell stays masked. Math.round on NaN yields 0.0, which would read as "these two samples do not correlate" — the opposite of "we do not know".
    • testSampleCorrelationSaysWhichMatrixItReturned

      @Test public void testSampleCorrelationSaysWhichMatrixItReturned()
      The default is `best`, and the response says which of the two it actually got.
    • testSampleCorrelationBestFallsBackToFullAndSaysSo

      @Test public void testSampleCorrelationBestFallsBackToFullAndSaysSo()
      No regressed matrix: `best` falls back to the full one and says so, rather than implying regression.
    • testSampleCorrelationFullIsServedOnRequestEvenWhenRegressedExists

      @Test public void testSampleCorrelationFullIsServedOnRequestEvenWhenRegressedExists()
      `matrix=full` takes the full one even when a regressed one exists — that is the whole point.
    • testSampleCorrelationRegressedDoesNotFallBack

      @Test public void testSampleCorrelationRegressedDoesNotFallBack()
      `matrix=regressed` never silently substitutes the full one; a dataset without one 404s.
    • testGetDatasetSampleCorrelationIsWithheldForSingleCell

      @Test public void testGetDatasetSampleCorrelationIsWithheldForSingleCell()
      🛑 Temporary, pending the per-cell-type design. A single-cell dataset's correlation matrix is the pseudo-bulk grid (samples × cell types), so its correlations are taken across cell types and the median-correlation outlier rule reads a rare cell type as an outlier. The assertion that matters is that the matrix is never even loaded: returning it and letting the caller decide is exactly what we are stopping, and a 404 reached after the load would still cost ~100 MB.
    • testGetDatasetSampleCorrelationStillServedWhenNotSingleCell

      @Test public void testGetDatasetSampleCorrelationStillServedWhenNotSingleCell()
      The same dataset, not single-cell, still serves the matrix — the gate is the flag, not the route.
    • testGetDatasetSampleCorrelationWhenDatasetMissingIs404

      @Test public void testGetDatasetSampleCorrelationWhenDatasetMissingIs404()
    • testGetDatasetMeanVariance

      @Test public void testGetDatasetMeanVariance()
    • testGetDatasetMeanVarianceWhenNoneIs404

      @Test public void testGetDatasetMeanVarianceWhenNoneIs404()
    • testGetDatasetMeanVarianceWhenDatasetMissingIs404

      @Test public void testGetDatasetMeanVarianceWhenDatasetMissingIs404()
    • testGetDatasetMeanVarianceIsRoundedAndLeavesTheEntityArrayAlone

      @Test public void testGetDatasetMeanVarianceIsRoundedAndLeavesTheEntityArrayAlone()
      Mean-variance is the heaviest diagnostics payload — one mean and one variance per probe. It has no precision opt-out.

      The second half of the assertion is the part that matters: MeanVarianceRelation.getMeans() hands back the loaded entity's own array, so rounding in place would corrupt it for every later reader.

    • testGetDatasetMeanVarianceKeepsOnePointPerCellAndStaysIndexParallel

      @Test public void testGetDatasetMeanVarianceKeepsOnePointPerCellAndStaysIndexParallel()
      Two points that land in the same grid cell collapse to the first of them, and the pairing survives: the fixture makes every variance twice its mean, so an entry dropped from one array and not the other would re-pair every point after it and break that relation.
    • testGetDatasetMeanVarianceLeavesWellSeparatedPointsAlone

      @Test public void testGetDatasetMeanVarianceLeavesWellSeparatedPointsAlone()
      Points that each get their own cell come back untouched — thinning only ever removes a point that would be drawn on top of one already sent.
    • testGetDatasetMeanVarianceDropsNonFinitePoints

      @Test public void testGetDatasetMeanVarianceDropsNonFinitePoints()
      A point whose mean or variance is not finite has no position on the scatter, so it is dropped rather than keyed into the grid. Both arrays lose it together.
    • testGetDatasetMeanVarianceCollapsesAHeavilyOverplottedDataset

      @Test public void testGetDatasetMeanVarianceCollapsesAHeavilyOverplottedDataset()
      The size guard: 30,000 points drawn from ten distinct coordinates come back as ten. This is the shape of the real saving — eid 1 sends 22,283 points of which 93% land where one has already been painted.
    • testGetDatasetMeanVarianceIsThinnedOnTheWire

      @Test public void testGetDatasetMeanVarianceIsThinnedOnTheWire()
      The thinned arrays are what actually goes on the wire.
    • testGetDatasetSvdIsRoundedAndLeavesTheResultArraysAlone

      @Test public void testGetDatasetSvdIsRoundedAndLeavesTheResultArraysAlone()
      SVD loadings serialize at a mean of ~20 characters each and nothing consumes the digits below it, so this route rounds with no opt-out. SVDResult.getVariances() / getVMatrix().getRawMatrix() are the result's own arrays, hence the non-mutation half.
    • testGetDatasetsExpressionLevelsForGeneIsRoundedUnlessPreciseIsAsked

      @Test public void testGetDatasetsExpressionLevelsForGeneIsRoundedUnlessPreciseIsAsked()
      Expression levels are a data-download surface, so rounding here is the default and precise=true is the opt-out. Both branches are asserted on the wire.
    • testListDatasetMetadataFilesEmpty

      @Test public void testListDatasetMetadataFilesEmpty() throws IOException
      Throws:
      IOException
    • testListDatasetMetadataFilesReturnsAvailable

      @Test public void testListDatasetMetadataFilesReturnsAvailable() throws IOException
      Throws:
      IOException
    • testGetDatasetMetadataFileWithUnknownTypeIs400

      @Test public void testGetDatasetMetadataFileWithUnknownTypeIs400()
    • testGetDatasetMetadataFileForDirectoryTypeIs400

      @Test public void testGetDatasetMetadataFileForDirectoryTypeIs400()
    • testGetDatasetMetadataFileWhenAbsentIs404

      @Test public void testGetDatasetMetadataFileWhenAbsentIs404() throws IOException
      Throws:
      IOException
    • testGetDatasetMetadataFileServesPayload

      @Test public void testGetDatasetMetadataFileServesPayload() throws IOException
      Throws:
      IOException
    • testGetDatasetMetadataFileWhenDatasetMissingIs404

      @Test public void testGetDatasetMetadataFileWhenDatasetMissingIs404()
    • testGetDatasetSvdLoadings

      @Test public void testGetDatasetSvdLoadings()
    • testGetDatasetSvdLoadingsWhenNoSvdIs404

      @Test public void testGetDatasetSvdLoadingsWhenNoSvdIs404()
    • testGetDatasetSvdLoadingsWithMissingPcIs400

      @Test public void testGetDatasetSvdLoadingsWithMissingPcIs400()
    • testGetDatasetSvdLoadingsWithPcZeroIs400

      @Test public void testGetDatasetSvdLoadingsWithPcZeroIs400()
    • testGetDatasetSvdLoadingsWithTopOverCapIs400

      @Test public void testGetDatasetSvdLoadingsWithTopOverCapIs400()
    • testGetDatasetSvdLoadingsWithUnknownDirectionIs400

      @Test public void testGetDatasetSvdLoadingsWithUnknownDirectionIs400()
    • testGetDatasetAllPublications

      @Test public void testGetDatasetAllPublications()
    • testUpdateDatasetPublications

      @Test @WithMockUser public void testUpdateDatasetPublications()
    • testUpdateDatasetPublicationsCarriesEvidence

      @Test @WithMockUser public void testUpdateDatasetPublicationsCarriesEvidence()
    • testUpdateDatasetPublicationsRecordsRejection

      @Test @WithMockUser public void testUpdateDatasetPublicationsRecordsRejection()
    • testUpdateDatasetPublicationsOmittingRejectedLeavesThemUntouched

      @Test @WithMockUser public void testUpdateDatasetPublicationsOmittingRejectedLeavesThemUntouched()
    • testUpdateDatasetPublicationsEmptyRejectedListStillClearsThem

      @Test @WithMockUser public void testUpdateDatasetPublicationsEmptyRejectedListStillClearsThem()
    • testUpdateDatasetPublicationsRejectedByHigherAuthorityIsConflict

      @Test @WithMockUser public void testUpdateDatasetPublicationsRejectedByHigherAuthorityIsConflict()
    • testUpdateDatasetPublicationsRejectsUnknownSource

      @Test @WithMockUser public void testUpdateDatasetPublicationsRejectsUnknownSource()
    • testUpdateDatasetPublicationsByDoi

      @Test @WithMockUser public void testUpdateDatasetPublicationsByDoi()
    • testUpdateDatasetPublicationsRejectsBothPubMedIdAndDoi

      @Test @WithMockUser public void testUpdateDatasetPublicationsRejectsBothPubMedIdAndDoi()
    • testUpdateDatasetPublicationsClearAll

      @Test @WithMockUser public void testUpdateDatasetPublicationsClearAll()
    • testUpdateDatasetPublicationsMissingOtherList

      @Test @WithMockUser public void testUpdateDatasetPublicationsMissingOtherList()
    • testUpdateDatasetPublicationsRejectsBlankPubMedId

      @Test @WithMockUser public void testUpdateDatasetPublicationsRejectsBlankPubMedId()
    • testUpdateDatasetPublicationsUnresolvablePubMedIdIsBadRequest

      @Test @WithMockUser public void testUpdateDatasetPublicationsUnresolvablePubMedIdIsBadRequest()
    • testUpdateDatasetBasics

      @Test @WithMockUser public void testUpdateDatasetBasics()
    • testUpdateDatasetBasicsDescriptionOnly

      @Test @WithMockUser public void testUpdateDatasetBasicsDescriptionOnly()
    • testUpdateDatasetBasicsRejectsEmptyBody

      @Test @WithMockUser public void testUpdateDatasetBasicsRejectsEmptyBody()
    • testUpdateDatasetBasicsRejectsBlankName

      @Test @WithMockUser public void testUpdateDatasetBasicsRejectsBlankName()
    • testCommitCurationPublications

      @Test @WithMockUser public void testCommitCurationPublications()
    • testCommitCurationBasics

      @Test @WithMockUser public void testCommitCurationBasics()
    • testCommitCurationTagWithStatementsReachesTheCommitAsAStatement

      @Test @WithMockUser public void testCommitCurationTagWithStatementsReachesTheCommitAsAStatement()
      A tag carrying nested statements must reach the commit as a Statement, not a bare Characteristic. cab reported it accepted and silently dropped: preflight said created=1, the commit returned 200 and minted a snapshot, and the stored row had PREDICATE NULL (GSE104324, CHARACTERISTIC 56965512, discriminator NULL = a plain Characteristic).
    • testCommitCurationTagKeepsBothStatements

      @Test @WithMockUser public void testCommitCurationTagKeepsBothStatements()
      A tag carrying two statements must store BOTH pairs. The converter used to read items[0] and discard the rest, so a two-statement tag silently became a one-statement tag — cab lost six statements across five tags that way (2026-08-31) and only found it by reading SECOND_PREDICATE in the database, because a tag that lost a claim looks exactly like one that never made it.
    • testCommitCurationRejectsAThirdStatementOnATag

      @Test @WithMockUser public void testCommitCurationRejectsAThirdStatementOnATag()
      A row holds two pairs, so a third claim is refused rather than silently dropped.
    • testCommitCurationRejectsAnUndeclaredUngroundedTag

      @Test @WithMockUser public void testCommitCurationRejectsAnUndeclaredUngroundedTag()
      An experiment tag with no value.uri is refused unless the caller says the free text is deliberate. Paul's ruling, 2026-09-01: reject, but give the client a way to declare intent — an ungrounded tag is usually an oversight, and after the fact it cannot be told apart from a grounding somebody meant to do and forgot.
    • testCommitCurationAcceptsADeclaredFreeTextTagThatIsHooked

      @Test @WithMockUser public void testCommitCurationAcceptsADeclaredFreeTextTagThatIsHooked()
      Declaring it accepts the same tag — the gate is the declaration, not the absence of a URI.
    • testCommitCurationRejectsADeclaredFreeTextTagWithNoHook

      @Test @WithMockUser public void testCommitCurationRejectsADeclaredFreeTextTagWithNoHook()
      A declared free-text tag that hangs off nothing is refused. Paul's ruling, 2026-09-06: a free-text experiment tag must give the reader grounded context for the text, so the declaration alone stopped being enough — freeTextIntended says the missing URI was deliberate, and says nothing about whether the annotation reaches the ontology anywhere.
    • testCommitCurationRejectsAFreeTextTagWhoseHookObjectIsUngrounded

      @Test @WithMockUser public void testCommitCurationRejectsAFreeTextTagWhoseHookObjectIsUngrounded()
      🛑 The hook has to reach a TERM. A predicate paired with an object that is itself free text leaves the tag exactly as unreachable as it was, so it is refused — this is the case that separates "has a statement" from "is grounded", and the check is worthless if it passes.
    • testCommitCurationAcceptsAFreeTextTagHookedOnItsSecondPair

      @Test @WithMockUser public void testCommitCurationAcceptsAFreeTextTagHookedOnItsSecondPair()
      The hook may sit in the row's SECOND predicate/object pair — a tag whose first claim is ungrounded and whose second reaches a term is still hooked.
    • testCommitCurationRejectsUnknownSection

      @Test @WithMockUser public void testCommitCurationRejectsUnknownSection()
    • testCommitRefusesOmittedEvidenceCodeOnAStatementThatHasOne

      @Test @WithMockUser public void testCommitRefusesOmittedEvidenceCodeOnAStatementThatHasOne()
      Omitting evidenceCode on a statement that has one is refused.

      Same hazard as supportingEvidence beside it and initially shipped without the guard: an omission cleared a stored IC and reported updated: 1, indistinguishable from the edit the caller meant. The clear is spelled "" here rather than an absent key, because the field is a String and Jackson cannot tell a missing key from an explicit null.

    • testCommitRefusesOmittedSecondPairOnAStatementThatHasOne

      @Test @WithMockUser public void testCommitRefusesOmittedSecondPairOnAStatementThatHasOne()
      🛑 Omitting the second predicate-object pair on a gemmaId statement that HAS one is refused (Paul, 2026-09-11: "Guard it like evidence. It's too dangerous."). applyStatementFields writes the pair from the payload unconditionally, so before this the omission cleared it and the commit reported updated: 1 -- the same report a successful edit gets. 9,338 production rows carry a pair.
    • testCommitAcceptsASecondPairEchoedExplicitly

      @Test @WithMockUser public void testCommitAcceptsASecondPairEchoedExplicitly()
      Echoing the pair explicitly keeps it, which is what a client that read the row sends back.
    • testCommitAcceptsASecondPairEchoedAsTheFlattenedSecondItem

      @Test @WithMockUser public void testCommitAcceptsASecondPairEchoedAsTheFlattenedSecondItem()
      The FLATTENED spelling counts as echoing it too — a second statements[] item under the same gemmaId, which is how Gemma SERIALIZES a compound statement and therefore how uib sends it back.
    • testCommitClearsASecondPairWhenClearSecondPairIsSent

      @Test @WithMockUser public void testCommitClearsASecondPairWhenClearSecondPairIsSent()
      Dropping a pair stays possible, but only when asked for in so many words.
    • testPreflightAppliesTheSameSecondPairRule

      @Test @WithMockUser public void testPreflightAppliesTheSameSecondPairRule()
      The preflight is the call a client makes FIRST, so it has to accept every document the commit would — clearSecondPair included — and refuse the ones it would refuse. Both routes go through doCommitCuration, so this pins the sharing rather than trusting it (uib asked, 2026-09-11).
    • testCommitRefusesClearSecondPairTogetherWithAPair

      @Test @WithMockUser public void testCommitRefusesClearSecondPairTogetherWithAPair()
      Asking to drop it and sending it are contradictory, so neither wins silently.
    • testCommitAllowsOmittingTheSecondPairWhenTheStoredRowHasNone

      @Test @WithMockUser public void testCommitAllowsOmittingTheSecondPairWhenTheStoredRowHasNone()
      No false positive: a statement with no stored pair may omit it, which is every ordinary statement.
    • testCommitRefusesOmittedEvidenceOnAStatementThatHasSome

      @Test @WithMockUser public void testCommitRefusesOmittedEvidenceOnAStatementThatHasSome()
      Omitting evidence on a row that HAS evidence is refused; sending [] clears it deliberately.

      The design section is full-record replacement, so an absent key clears — harmless on a row holding none, silent destruction on one that does. Absent and [] are distinguishable because the field is a JsonNode, so the deliberate clear stays available while the accidental one is refused.

    • testCommitCurationRefusesAGemmaIdStatementWithNoSubject

      @Test @WithMockUser public void testCommitCurationRefusesAGemmaIdStatementWithNoSubject()
      A partial statement item is refused rather than applied.

      A gemmaId statement is updated in place from the fields it carries, so an item sending only one field writes null over the rest. On 2026-09-05 an item carrying only supportingEvidence blanked a live statement's subject, subjectUri and category, dropped the annotation out of /annotations, and still reported updated: 1 — indistinguishable from success.

    • testCommitCurationDesignPreflightIsGivenTheDeferredBindings

      @Test @WithMockUser public void testCommitCurationDesignPreflightIsGivenTheDeferredBindings()
      The preflight has to be handed the PLAN, not only the design payload. A sample bound to a factor value the commit creates lives nowhere else: the design VO names factor values by id and the new one has none until the first apply pass makes it, so the binding waits in DesignCommitPlan.pendingAssignments. A report built without it counts no changed biomaterials for a create whose bindings do land.
    • testCommitCurationDesignCreatesFactor

      @Test @WithMockUser public void testCommitCurationDesignCreatesFactor()
    • testCommitCurationDesignDeletesByDeletedIds

      @Test @WithMockUser public void testCommitCurationDesignDeletesByDeletedIds()
    • testCommitCarriesForwardUnmentionedFactorValuesAndStatements

      @Test @WithMockUser public void testCommitCarriesForwardUnmentionedFactorValuesAndStatements()
      Factor values and statements the document does not mention reach the service unchanged.
    • testCommitRefusesOmittedEvidenceOnAFactorThatHasSome

      @Test @WithMockUser public void testCommitRefusesOmittedEvidenceOnAFactorThatHasSome()
      A gemmaId factor item that omits evidence the factor HAS is refused, not applied as a clear.
    • testCommitRefusesOmittedEvidenceOnAFactorValueThatHasSome

      @Test @WithMockUser public void testCommitRefusesOmittedEvidenceOnAFactorValueThatHasSome()
      The same rule one level down: a factor value item that omits the value's evidence is refused.
    • testCommitRefusesFactorValueDeletedIdThatIsNotOnThatFactor

      @Test public void testCommitRefusesFactorValueDeletedIdThatIsNotOnThatFactor()
      A factor value deletedIds entry that is not a value of that factor is refused.
    • testCommitCurationDesignNullSamplesLeavesAssignmentUntouched

      @Test @WithMockUser public void testCommitCurationDesignNullSamplesLeavesAssignmentUntouched()
    • testCommitCurationDesignEmptySamplesClearsAssignment

      @Test @WithMockUser public void testCommitCurationDesignEmptySamplesClearsAssignment()
    • testCommitCurationDesignForceGateIs409

      @Test @WithMockUser public void testCommitCurationDesignForceGateIs409()
    • testPreflightCurationDesignIsDryRun

      @Test @WithMockUser public void testPreflightCurationDesignIsDryRun()
    • testCommitCurationTags

      @Test @WithMockUser public void testCommitCurationTags()
    • testCommitAnswers400WhenADeletedIdNamesNothingOnTheDataset

      @Test @WithMockUser public void testCommitAnswers400WhenADeletedIdNamesNothingOnTheDataset()
      A delete naming nothing on the dataset is a malformed body: 400, like the design section, not a 409.
    • testCommitRejectsDecoratedTagKeepMarker

      @Test @WithMockUser public void testCommitRejectsDecoratedTagKeepMarker()
      A tags item bearing a gemmaId is a keep-marker: the section is add/delete only, so the mapper reads the id and nothing else. Decorating one used to be a 200 for an edit that never happened — a client that set supportingEvidence on an existing tag was told it had, and had not — so any other field is now a 400. Every offending field is named in one response: a caller told about them one at a time strips its payload one round trip at a time, and this fires mid-campaign.
    • testPreflightRejectsDecoratedTagKeepMarker

      @Test @WithMockUser public void testPreflightRejectsDecoratedTagKeepMarker()
      The preflight shares the mapper, so it refuses exactly what the commit refuses. A dry run that accepted a payload the commit rejects would stop being a rehearsal.
    • testCommitRejectsDecoratedKeepMarkersInEverySectionAtOnce

      @Test @WithMockUser public void testCommitRejectsDecoratedKeepMarkersInEverySectionAtOnce()
      sampleCharacteristics has the same add/delete-only shape and discarded decoration the same way, so it gets the same refusal — bioassayShortName included, which on a keep-marker reads like "move this characteristic to that sample" and does nothing. Both sections report in one response, the way term violations do.
    • testRestoreOfASnapshotWithSurvivingTagsIsNotRefusedAsDecorated

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRestoreOfASnapshotWithSurvivingTagsIsNotRefusedAsDecorated()
      A restore replays a SNAPSHOT, and a snapshot records the content of every row — including the rows the restore only has to leave alone. The reconciliation strips that content off the items that keep their id, so the document the restore builds for itself is legal under the keep-marker rule the commit enforces. Nothing is lost: a decorated keep-marker's content was never applied.

      Guards the one way this rule could break Gemma's own writes rather than a client's.

    • testRestoreRecreatesAFreeTextTagWithNoHook

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRestoreRecreatesAFreeTextTagWithNoHook()
      A restore re-creates a tag its snapshot holds, and the free-text experiment-tag checks do not apply to it (Paul's ruling, 2026-09-13). uib measured the refusal on gemma2: snapshot 2116 of dataset 2706, on CBA/J x C57Bl/6J, which has no URI and no hook. The same tag on PUT /curation is still refused — testCommitCurationRejectsADeclaredFreeTextTagWithNoHook().
    • testRestoreClearsASecondPairAddedAfterTheSnapshot

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRestoreClearsASecondPairAddedAfterTheSnapshot()
      cab, 2026-09-13: 15 of 21 restore points on gemma2 answered 400, each on a statement a commit had given a second pair after the snapshot was taken. A snapshot that records pairs is the target state, so the live pair is cleared (Paul's ruling, 2026-09-13).
    • testRestoreOfASnapshotFromBeforePairsWereRecordedKeepsTheLivePair

      @Test @WithMockUser(authorities="GROUP_ADMIN") public void testRestoreOfASnapshotFromBeforePairsWereRecordedKeepsTheLivePair()
      A snapshot captured before snapshots recorded pairs cannot say whether the statement had one, so the restore keeps the live pair rather than clearing what the snapshot never saw (Paul's ruling, 2026-09-13). Together with the test above, this separates the two eras: clearing always fails this one, keeping always fails that one.
    • testCommitCurationTagCarriesSupportingEvidence

      @Test @WithMockUser public void testCommitCurationTagCarriesSupportingEvidence()
      The tag's supportingEvidence reaches the Characteristic handed to the service. The section had no coverage at all for this field — the only evidence guard was on design statements — so a mapper that accepted it and built a Characteristic without it would have been invisible here.
    • testCommitCurationTagCarriesEvidenceCode

      @Test @WithMockUser public void testCommitCurationTagCarriesEvidenceCode()
      A stated evidence code reaches the Characteristic instead of being left to the add path's default.
    • testCommitCurationTagWithoutEvidenceCodeLeavesItUnset

      @Test @WithMockUser public void testCommitCurationTagWithoutEvidenceCodeLeavesItUnset()
      Omitting the field leaves the Characteristic's code null, which is what hands the row to ExpressionExperimentWriteServiceImpl#addCharacteristic's IC fallback — the code every tag written through this route has carried. The guard is that the mapper stamps NOTHING of its own: a server that picked a code here (from the caller's identity, say) would put a value nobody chose on the row.
    • testCommitCurationRejectsUnknownEvidenceCode

      @Test @WithMockUser public void testCommitCurationRejectsUnknownEvidenceCode()
      An unknown code is a 400, not a silent drop. Dropping it would leave the row on the server default while the caller believed it had set one — the failure this field exists to end.
    • testPreflightRejectsUnknownEvidenceCode

      @Test @WithMockUser public void testPreflightRejectsUnknownEvidenceCode()
      The preflight enforces the same gate, so a client catches a bad code on the dry run.
    • testCommitCurationDesignStatementCarriesEvidenceCode

      @Test @WithMockUser public void testCommitCurationDesignStatementCarriesEvidenceCode()
      A design factor-value statement carries its own code, normalized to the enum name. Sent lowercase here: the apply compares the proposed code against the stored uppercase one, so an un-normalized "iea" would read as a change on every re-send.
    • testCommitCurationRejectsUnknownEvidenceCodeOnADesignStatement

      @Test @WithMockUser public void testCommitCurationRejectsUnknownEvidenceCodeOnADesignStatement()
      A bad code inside the design tree is rejected too, located in the design tree.
    • testCommitCurationSampleCharacteristics

      @Test @WithMockUser public void testCommitCurationSampleCharacteristics()
    • testCommitCurationSampleCharacteristicsUnknownSampleIs400

      @Test @WithMockUser public void testCommitCurationSampleCharacteristicsUnknownSampleIs400()
    • testCommitCurationDetailsNote

      @Test @WithMockUser public void testCommitCurationDetailsNote()
    • testCommitCurationDetailsFlagsAre400

      @Test @WithMockUser public void testCommitCurationDetailsFlagsAre400()
    • testPreflightCurationIsDryRun

      @Test @WithMockUser public void testPreflightCurationIsDryRun()
    • testCommitCurationStaleBaselineIs409

      @Test @WithMockUser public void testCommitCurationStaleBaselineIs409()
    • testGetDatasetsPipelineStatusReturnsAnEntryPerDatasetAlongsideTheSingleRoute

      @Test @WithMockUser public void testGetDatasetsPipelineStatusReturnsAnEntryPerDatasetAlongsideTheSingleRoute()
      The bulk route lives at the LITERAL `/datasets/pipelineStatus` with ids in a query param. `/datasets/{datasets}/pipelineStatus` would be the same JAX-RS template as the single-dataset route — a path parameter's name does not distinguish it — so this test also pins that the two coexist rather than shadowing one another.
    • testPipelineStatusCarriesTheEffectiveTriageVerdictAndJudgeKind

      @Test @WithMockUser public void testPipelineStatusCarriesTheEffectiveTriageVerdictAndJudgeKind()
    • testPipelineStatusTriageIsNullWhenNothingHasBeenTriaged

      @Test @WithMockUser public void testPipelineStatusTriageIsNullWhenNothingHasBeenTriaged()
      Nothing triaged leaves both fields null rather than defaulting — that is how a caller tells "not triaged" from "triaged Fine", which a boolean could not express.
    • testTriageDoesNotTouchTheCuratorNeedsAttentionFlag

      @Test @WithMockUser public void testTriageDoesNotTouchTheCuratorNeedsAttentionFlag()
      needsAttention is the pre-agent curator flag and must NOT move with triage: the two are separate signals, and collapsing them was the thing this design turned down.
    • testPlatformsOriginalRoutesToTheOriginalPlatforms

      @Test @WithMockUser public void testPlatformsOriginalRoutesToTheOriginalPlatforms()
      `?original=true` must actually route. A declared parameter that is quietly ignored is the failure mode uib caught on element search — the caller sees a 200 and a plausible body, and cannot tell that the question they asked was dropped.
    • testPlatformsOriginalIsEmptyRatherThanEchoingTheCurrentPlatform

      @Test @WithMockUser public void testPlatformsOriginalIsEmptyRatherThanEchoingTheCurrentPlatform()
      An unswitched dataset answers with an empty list, never with its current platform.
    • testGetDatasetSourceMetadata

      @Test public void testGetDatasetSourceMetadata()
      The document is served as an object, not as the string it is stored as. A string would make every consumer parse it themselves, and the envelope around it is already JSON.
    • testGetDatasetSourceMetadataWhenNoneHasBeenHarvested

      @Test public void testGetDatasetSourceMetadataWhenNoneHasBeenHarvested()
      🛑 Nothing harvested is the normal state for most of the corpus, not an error. A 404 here would be indistinguishable from a dataset that does not exist, and would make an ordinary experiment look broken in the curation UI.
    • testGetDatasetSourceMetadataForAnUnknownDataset

      @Test public void testGetDatasetSourceMetadataForAnUnknownDataset()
      A dataset that does not exist is still a 404, which is what the null case must not look like.
    • testSubsetWithNoFactorValuesYieldsAnEmptyListNotANullPointer

      @Test public void testSubsetWithNoFactorValuesYieldsAnEmptyListNotANullPointer()
      🛑 A subset with no factor values is normal, not an error. Single-cell subsets are cut from a cell-level characteristic and never carry one, so the map lookup feeding the VO misses and hands over null — which made /subSetGroups a 500 on exactly those datasets (44580 failed, factor-cut 38390 succeeded).
    • testCommitCurationWritesASecondPairOnANewStatement

      @Test @WithMockUser public void testCommitCurationWritesASecondPairOnANewStatement()
      A NEW statement can carry a second predicate-object pair.

      Until StatementCommit gained the fields there was no spelling for this. The flattened form — two statements[] entries sharing one id — is re-joined by unflattenStatements, but that keys on a non-null id, so an id-less row passed through as two separate single-clause statements. cab measured 9,031 production rows carrying a pair no client could create (2026-09-08).

    • testCommitCurationRefusesHalfASecondPair

      @Test @WithMockUser public void testCommitCurationRefusesHalfASecondPair()
      Half a pair is not a claim: a dangling predicate would sit on a production row with nothing to render it.
    • testCommitCurationRefusesHalfAFirstPair

      @Test @WithMockUser public void testCommitCurationRefusesHalfAFirstPair()
      The first pair is held to the same rule. Six factor-value statements reached production with a predicate and no object.
    • testCommitCurationRefusesHalfAPairOnATagStatement

      @Test @WithMockUser public void testCommitCurationRefusesHalfAPairOnATagStatement()
      A tag's statement goes through the same guard as a factor value's.
    • testCommitCurationTagAcceptsAnExplicitSecondPair

      @Test @WithMockUser public void testCommitCurationTagAcceptsAnExplicitSecondPair()
      A tag's second pair can be spelled explicitly too, which is the only way to state one on a tag whose statement is new. The two-item form keeps working; using both at once is refused rather than merged.
    • testCommitCurationRefusesBothSpellingsOfATagsSecondPair

      @Test @WithMockUser public void testCommitCurationRefusesBothSpellingsOfATagsSecondPair()
      Both spellings of the same pair on one tag is ambiguous, so it is a 400 rather than a silent winner.