Class DatasetsCurationCommitRestTest

java.lang.Object
org.glassfish.jersey.test.JerseyTest
All Implemented Interfaces:
org.springframework.beans.factory.Aware, org.springframework.context.ApplicationContextAware

@Tag("integration") @Tag("slow") public class DatasetsCurationCommitRestTest extends BaseJerseyIntegrationTest5
End-to-end regression harness for the composite curation commit (PUT /datasets/{id}/curation) against a real (gemdtest) database — the persistence half the mocked DatasetsWebServiceTest can't reach. Seeds one full experiment (design + factors + factor-values + biomaterials + bioassays with GEO-style accessions), PUTs a real CurationDocument, and asserts the persisted state on read-back.

Runs as admin (see BaseJerseyIntegrationTest5), so ACL edit + the admin-gated force path are satisfied. Grows one method per section as the phases land (design → tags → sampleCharacteristics → curationDetails).

  • Constructor Details

    • DatasetsCurationCommitRestTest

      public DatasetsCurationCommitRestTest()
  • Method Details

    • seedExperiment

      @BeforeEach public void seedExperiment()
    • removeExperiment

      @AfterEach public void removeExperiment()
    • testCommitDesignCreatesFactorAssignsSampleAndBaseline

      @Test public void testCommitDesignCreatesFactorAssignsSampleAndBaseline()
    • testCommitPersistsFactorAndFactorValueSupportingEvidence

      @Test public void testCommitPersistsFactorAndFactorValueSupportingEvidence()
      Provenance at the FACTOR and FACTOR VALUE levels survives the commit.

      Neither level had anywhere to land before: supportingEvidence existed on TagCommit, StatementCommit and SampleCharacteristicCommit, so a curator's justification for the factor itself, or for a value's label / baseline / measurement, was accepted by Jackson and silently dropped. Measured across the reference 500 that was 78 of 493 evidence blocks — 68 on factor values, 10 on factors.

      The statement evidence is asserted alongside on purpose: the three levels are separate slots, and the failure this guards against is one of them being made to stand in for another.

    • testBaselineRelevanceRoundTripsAndAnOverrideOnlyCommitIsNotANoOp

      @Test public void testBaselineRelevanceRoundTripsAndAnOverrideOnlyCommitIsNotANoOp()
      The baseline-relevance hint round-trips, and a commit that carries ONLY the hint is applied.

      The second half is the part that can fail quietly. Ticking "no baseline" with a reason moves no structural counter — no factor or value created, deleted or reassigned — so before keptFactorMetadataEdits learned about the two fields, isNoOpDesignApply would have called the commit a no-op and returned 200 having written nothing. Same blind spot the factor-value evidence clause exists to cover.

    • testSubsetRelevanceRoundTripsAndAnOverrideOnlyCommitIsNotANoOp

      @Test public void testSubsetRelevanceRoundTripsAndAnOverrideOnlyCommitIsNotANoOp()
      The subset-relevance hint round-trips, and a commit that carries ONLY the hint is applied.

      Mirrors testBaselineRelevanceRoundTripsAndAnOverrideOnlyCommitIsNotANoOp() because the field mirrors baselineRelevance deliberately — same open vocabulary, same null-leaves / empty-clears convention, same keptFactorMetadataEdits blind spot if the two clauses are missing: recommending a subset factor moves no structural counter, so isNoOpDesignApply would call the commit a no-op and return 200 having written nothing.

      🛑 The hint is ADVICE and is not the record of what an analysis did. That lives on the analysis as subsetFactorValue and is not settable here, which is why nothing in this test asserts a relationship between the two.

    • testOmittingEvidenceOnARowThatHasSomeIsRefused

      @Test public void testOmittingEvidenceOnARowThatHasSomeIsRefused()
      A commit that omits supportingEvidence CLEARS it — the design section is full-record replacement.

      🛑 This test asserted the opposite until 2026-09-06, and its old rationale was the real objection: treating omission as "clear it" lets a client with no provenance of its own erase provenance somebody else recorded, just by editing a label. That is now the contract, ruled by Paul after the alternative proved worse — evidence was the ONLY delta field on an otherwise replacement object, so one item carried two contracts, and the split made both of 2026-09-05's incidents possible: a delta-shaped item silently blanked a live statement, and evidence could be set and changed but never removed because there was no spelling of "I intend none".

      ⚠️ And the dangerous consequence is REFUSED rather than performed: a client that relabels a value without carrying the evidence back would destroy it, so it gets a 400 naming the entity. `[]` still clears deliberately — absent and empty are distinguishable because the field is a JsonNode, which the scalar fields cannot do. Nothing here means "leave unchanged"; that hybrid is what the section moved away from.

    • testAnEmptyEvidenceArrayClearsStoredEvidence

      @Test public void testAnEmptyEvidenceArrayClearsStoredEvidence()
      An EMPTY evidence array means "the row should have none", so it CLEARS.

      🛑 Inverted 2026-09-06 with the rest of the section's move to full-record replacement. Under that contract [] and an omitted key say the same unambiguous thing — not present in my intent — which is what makes evidence clearable at all; it had no spelling for "remove this" before.

      ⚠️ The old rationale named a hazard that this change makes REAL, and it is worth keeping in view rather than deleting: a payload built from a reference file stamps [] on every entity that has no evidence, which is most of them, so ONE bulk commit now clears evidence everywhere it touches and reports an ordinary success. The protection is no longer server-side; it is that every client sends the record it intends. CAB already coerces [] away client-side, which under the new contract is no longer enough — omitting it clears too.

      Asserted at all three levels because a level left out is a level where the behaviour silently differs.

    • testPreflightDesignWritesNothing

      @Test public void testPreflightDesignWritesNothing()
    • testCommitTagsAddThenDeleteById

      @Test public void testCommitTagsAddThenDeleteById()
    • testCommitPersistsTagSupportingEvidenceAndEvidenceCode

      @Test public void testCommitPersistsTagSupportingEvidenceAndEvidenceCode()
      The tags section had no transaction-boundary coverage for either provenance slot: the only guard was testCommitPersistsStatementSupportingEvidence(), on design statements. A mapper that accepted supportingEvidence and built a Characteristic without it, or a commit path that dropped it below the REST layer, would have been invisible — the request returns 200 either way and the only way to learn otherwise is to read the row back.

      The evidence code is asserted in the same test because it shares that blind spot exactly, and because a stated code is the whole point: without one the tag is recorded as IC, a curator's own inference, whoever wrote it.

    • testCommitWithoutAnEvidenceCodeStillRecordsIC

      @Test public void testCommitWithoutAnEvidenceCodeStillRecordsIC()
      Omitting the code leaves the add path's IC in place — the behaviour every tag on this route has had.
    • testCommitSampleCharacteristic

      @Test public void testCommitSampleCharacteristic()
    • testCommitCurationNote

      @Test public void testCommitCurationNote()
    • testCurationDetailsTroubledIsRejected

      @Test public void testCurationDetailsTroubledIsRejected()
    • testExistingFvWithNullSamplesKeepsAssignments

      @Test public void testExistingFvWithNullSamplesKeepsAssignments()
    • testCommitAdvancesLastUpdated

      @Test public void testCommitAdvancesLastUpdated()
    • testCommitDeletesFactorAndRecreatesItInOneCommit

      @Test public void testCommitDeletesFactorAndRecreatesItInOneCommit()
      W1 — a factor is re-categorized by replacing it wholesale: the old factor goes out via deletedIds and a new one arrives with clientRef, in ONE commit. The samples must end up on the new factor's values with nothing left parented to the deleted factor.

      This is the shape the curation side reaches for when every factor value is replaced anyway, so keeping the parent id would preserve nothing real while making a changed factor look unchanged to everything holding a reference.

    • testCommitMovesSamplesFromAnExistingFactorValueToANewOne

      @Test public void testCommitMovesSamplesFromAnExistingFactorValueToANewOne()
      W6 — the most foreign-key-sensitive case on the list: within one factor, some samples move OUT of an existing factor value INTO a newly created one, while the donor factor value survives (it is neither deleted nor recreated) and a sibling is relabelled in place.
    • testCommitRetermsOneFactorValueLeavingSiblingsUntouched

      @Test public void testCommitRetermsOneFactorValueLeavingSiblingsUntouched()
      W4 — one factor value is re-termed in place and every sibling is left alone. This is the case where preserving identity is required: an API that could only delete and recreate at factor granularity would make a one-term correction destructive.
    • testCommitRefusesStatementDeletedIdThatIsNotOnThatFactorValue

      @Test public void testCommitRefusesStatementDeletedIdThatIsNotOnThatFactorValue()
      A statement deletedIds entry naming a row that is not on that factor value is refused rather than absorbed. It used to answer 200 with deleted: 0: the delete is a suppression of the carry-forward, so an id that was never carried forward suppresses nothing and reads exactly like a delete that worked. A caller recorded eight such deletions against eid 6146 on 2026-09-01.
    • testDryRunRefusesTheSameUnmatchedDeletedId

      @Test public void testDryRunRefusesTheSameUnmatchedDeletedId()
      The dry run refuses it too, so a client can find out its document is stale without writing anything.
    • testCommitFactorDescriptionOnlyEditPersists

      @Test public void testCommitFactorDescriptionOnlyEditPersists()
      W11 — a factor description-only edit reaches the database. Nothing structural moves, so this used to be short-circuited as a no-op and dropped without a word; the client saw 200 and no change.
    • testCommitContinuousFactorValueCarriesMeasurementAndUnit

      @Test public void testCommitContinuousFactorValueCarriesMeasurementAndUnit()
      W14 — a continuous factor value keeps its measurement AND its unit. Measurement.unit does not cascade on persist, so a unit that isn't resolved to a persistent row lands as a bare number.
    • testCommitDeletesContinuousFactorWithItsMeasurementValues

      @Test public void testCommitDeletesContinuousFactorWithItsMeasurementValues()
      W3 — deleting a continuous factor takes its measurement-bearing factor values with it. Worth its own case because continuous factor values carry Measurement rows that categorical ones don't.
    • testCommitPreservesUngroundedAndNonAsciiFreeText

      @Test public void testCommitPreservesUngroundedAndNonAsciiFreeText()
      W8 / W16 — deliberately ungrounded free text (JAX strain nomenclature has no ontology term, and the curation side files these as free text on purpose) survives with its characters intact. The non-ASCII half matters because our GEO import mangled unicode in 68 experiments and the curated text is the repair.
    • testCommitPersistsStatementSupportingEvidence

      @Test public void testCommitPersistsStatementSupportingEvidence()
      W13 — a curated statement's justification survives the real persist. Until now the composite commit had nowhere to put one on any section, so curation written through this path arrived stripped of the reason it was made and acquired only "modified by the API user".
    • testCommitStrandingASubsetRequiresForce

      @Test public void testCommitStrandingASubsetRequiresForce()
      A change that would strand a subset on deleted factor values needs the same explicit consent as the analysis cascade: 409 without force, applied with it. The stranded subset is the more dangerous of the two because it survives the change still looking valid.
    • testSnapshotThenRelabelThenRestoreReturnsTheOriginalLabel

      @Test public void testSnapshotThenRelabelThenRestoreReturnsTheOriginalLabel()
      The basic loop: snapshot, relabel a factor value, then restore and find the original label back. Nothing structural moves, so every id survives and the restore is a true revert.
    • testRestoreRemovesAFactorTheAgentAddedAfterTheSnapshot

      @Test public void testRestoreRemovesAFactorTheAgentAddedAfterTheSnapshot()
      A restore has to undo additions too, not just edits. The commit is declared-delete, so anything the agent added since the snapshot has to be named in deletedIds by the reconciliation — otherwise it silently survives a "restore" and the dataset is left in a state that matches no snapshot at all.
    • testRestoreAfterADeleteReturnsContentUnderANewId

      @Test public void testRestoreAfterADeleteReturnsContentUnderANewId()
      The identity caveat, pinned as behaviour rather than left in a doc comment. When the agent deletes a factor and the snapshot is replayed, the factor comes back by content under a NEW id — the row the snapshot named is gone and no amount of replay resurrects it. A caller that assumes restore is an id-for-id revert would be wrong, and this is where they find out.
    • testRestoringAnUnchangedDatasetIsANoOp

      @Test public void testRestoringAnUnchangedDatasetIsANoOp()
      Restoring an unchanged dataset changes nothing: the snapshot round-trips as a no-op.
    • testRestoringANonSnapshotIsRejected

      @Test public void testRestoringANonSnapshotIsRejected()
      Only a SNAPSHOT can be restored — replaying some other tool's payload shape as a commit is a 400.
    • testForceIsNotABypassForANonAdmin

      @Test public void testForceIsNotABypassForANonAdmin()
      force=true is not a bypass. A non-admin cannot push a destructive design change through, and nothing is written when they try.

      Scope, so nobody inherits a hunt with no prize at the end of it: in production every curator is an admin (Paul, 2026-08-16), and only admins delete factors. The non-admin this test constructs is therefore not a user class that exists, and the branch it exercises is not one real users reach.

      Still worth keeping as defence in depth — it pins that force=true cannot be used to push a destructive change through as a non-admin.

      🛑 The change has to be one that needs force, or the gate is never reached and the request is an ordinary edit that a GROUP_USER holding ACL edit is entitled to make — commitCuration is @Secured({"GROUP_USER", "ACL_SECURABLE_EDIT", "RUN_AS_AGENT"}), and this test grants the curator exactly that by making them the owner. So the subset is anchored first, the same way testCommitStrandingASubsetRequiresForce() does it: the seeded experiment carries no differential-expression analyses, so stranding a subset is the only half of requiresForce() a fixture can make true. Without it the delete is not destructive in the sense the gate means, the predicate is false, and the commit lands with a 200 — which is what this test used to assert against, so it was refusing a request the security model permits rather than exercising the force gate.

      Worth having because every other test in this class runs as admin — BaseJerseyIntegrationTest5 authenticates in a @BeforeEach — so without switching identity inside the test body the admin branch is the only one ever exercised and force=true would look unconditional. ( @WithMockUser does not work here: the base @BeforeEach overwrites the context.)

    • testPreflightReportsTheForceGateItDoesNotTrip

      @Test public void testPreflightReportsTheForceGateItDoesNotTrip()
      A preflight REPORTS the force gate it deliberately does not trip.

      🛑 A dry run never 409s — that is the contract — so if the report says nothing about the consequences, a caller has no way at all to learn the real PUT will be refused. It was computed on every curation preflight and discarded until now: cab preflighted GSE19804 clean on 2026-09-09 and had the PUT refused 409 REQUIRES_FORCE immediately after, because the change would delete DEA 432031 — which the discarded report had already named.

      The PUT half is the point of the test rather than a second case: it establishes that the preflight predicted the actual outcome, not merely that some boolean is set.

    • testPreflightOmitsTheDesignReportWhenThereIsNoDesignSection

      @Test public void testPreflightOmitsTheDesignReportWhenThereIsNoDesignSection()
      🛑 A commit with no design section reports designReport ABSENT, not empty.

      Nothing computes the report for a tags-only commit, so nothing may claim it came back clean. An empty report would assert the question was asked and answered; absent says it was never asked.

    • testTakingASnapshotDoesNotMarkTheDatasetAsUpdated

      @Test public void testTakingASnapshotDoesNotMarkTheDatasetAsUpdated()
      A backup must not modify what it backs up.

      Every audit event on a curatable sets curationDetails.lastUpdated to the event date (AbstractCuratableDao#updateCurationDetailsFromAuditEvent, unconditionally, for every event type). So an AnnotationSetEvent emitted when a snapshot is captured makes the dataset look edited when nothing about it changed.

      That is not cosmetic. lastUpdated is the optimistic-concurrency token the curation commit checks (baseline.lastModified → 409), so taking a backup would invalidate every in-flight curator draft on that dataset. It also perturbs anything ordering datasets by recency, and the dataset shows as touched in Gemma 1.0, which reads the same database.

      Observed on gemma2: snapshotting GSE11630 moved its lastUpdated to 79 ms after the snapshot's createdAt. The AnnotationSet row already records that a backup was taken, with its own createdAt, createdBy and runId — the audit event is redundant for a capture that changes nothing.

    • testCommitWithRunRefMintsCommitAnnotationSet

      @Test public void testCommitWithRunRefMintsCommitAnnotationSet()
      Naming a run mints a COMMIT annotation set carrying that run's build, and the report points at it.
    • testCommitWithoutRunRefMintsNothing

      @Test public void testCommitWithoutRunRefMintsNothing()
      Provenance is sparse on purpose: an ordinary curator commit names no run and mints nothing.
    • testRunProvenanceWithoutRunIdIsRejected

      @Test public void testRunProvenanceWithoutRunIdIsRejected()
      Provenance with no run to hang it off cannot be stored. Accepting it would drop the fields silently while the caller believes it recorded them.
    • testPreflightWithRunRefMintsNothing

      @Test public void testPreflightWithRunRefMintsNothing()
      A preflight writes nothing, so it mints no row either — the run reference is only shape-checked.
    • testSameRunCommittingTwiceKeepsOneRow

      @Test public void testSameRunCommittingTwiceKeepsOneRow()
      One run committing the same dataset twice keeps one row. The unique key is (investigation, role, runId), and a resumed run reuses its id by design.
    • testNoOpCommitNamingARunStillMints

      @Test public void testNoOpCommitNamingARunStillMints()
      A no-op commit that named a run still mints. Absence has to mean "no run was named", never "the run did nothing" — those are different facts and would otherwise have identical bytes.
    • testCommitNamingANonProposalParentIsRejected

      @Test public void testCommitNamingANonProposalParentIsRejected()
      A COMMIT may only name a PROPOSAL as the thing it applied. Proposed-versus-applied is the distinction the provenance surface rests on, so a DRAFT or SNAPSHOT in that slot is a client bug, not a silent no-op.
    • testNamingARunCostsNoExtraAuditEvent

      @Test public void testNamingARunCostsNoExtraAuditEvent()
      Naming a run must not cost an extra audit event.

      Every audit event on a curatable sets curationDetails.lastUpdated (AbstractCuratableDao#updateCurationDetailsFromAuditEvent, unconditionally), and that is the optimistic-concurrency token the next commit checks. One commit already emits several events; an AnnotationSetEvent for the COMMIT row on top would be one more, saying nothing the section events did not — so AnnotationSetServiceImpl#ATTACH_AUDIT_WHEN suppresses it for COMMIT as it does for SNAPSHOT. This pins that: the same commit costs the same events whether or not it names a run.

    • testPreflightEmitsNoAuditEvent

      @Test public void testPreflightEmitsNoAuditEvent()
      A preflight must not audit. cab describes their permitted set under the Gemma 1.0 hold as "reads and preflight only", and that phrase is only true if a dry run emits nothing: every audit event on a curatable sets curationDetails.lastUpdated, and AnnotationSetEvent / DesignChangeEvent are among the 21 discriminators Gemma 1.0 cannot load (PR #1667) — so an auditing preflight would both move the concurrency token and break the 1.0 experiment page.

      Verified by reading the code once (previewDesignChange is @Transactional(readOnly = true) with no audit call in 300 lines); pinned here so it stays true.

      The tag carries URIs because a preflight runs the same grounding gate as the commit: a new tag whose value has no URI and no freeTextIntended is refused as UNGROUNDED_NOT_DECLARED. A 400 there never reaches the audit-event count this test is about, so it read as a passing preflight-emits-nothing assertion when it was really a rejected request.

      Since 2026-09-06 the declaration alone would not rescue it either: an ungrounded tag also needs a statement pairing a predicate with a grounded object (FREE_TEXT_NOT_HOOKED). Grounding the value, as this test does, sidesteps both.

    • testCommitKeepsWhatItDisplacedAndTheReportPointsAtIt

      @Test public void testCommitKeepsWhatItDisplacedAndTheReportPointsAtIt()
      The restore point nobody had to remember to ask for. A commit that changes anything first stores the curation it is about to overwrite as a SNAPSHOT, and the id it reports feeds the ordinary restore — so the undo exists even though the curator took no backup beforehand.
    • testANoOpCommitKeepsNoSnapshot

      @Test public void testANoOpCommitKeepsNoSnapshot()
      A commit that changes nothing displaced nothing, so it keeps nothing. A row per retry would bury the restore points that matter under identical copies of a state nobody overwrote.
    • testPreflightKeepsNoSnapshot

      @Test public void testPreflightKeepsNoSnapshot()
      A preflight writes nothing, so there is nothing to keep and no row left behind to explain.
    • testKeepingTheDisplacedCurationCostsNoAuditEvent

      @Test public void testKeepingTheDisplacedCurationCostsNoAuditEvent()
      Keeping the displaced curation has to be free in audit events. Every audit event on a curatable sets curationDetails.lastUpdated — the optimistic-concurrency token the next commit checks — so a backup that audited would 409 in-flight drafts by the act of backing up. A basics-only commit emits no event of its own, which is what makes the count here a direct reading of the backup's cost.
    • testCommitRecordsTheBasisGivenForAPublication

      @Test public void testCommitRecordsTheBasisGivenForAPublication()
      The evidence a commit is given is recorded, not dropped. PublicationEntry carries the basis for a link on every endpoint that accepts one, and a commit that quietly kept only the identifier would leave the caller believing it had recorded a reason it never stored.
    • testRestoreBringsBackADroppedPublicationWithItsBasis

      @Test public void testRestoreBringsBackADroppedPublicationWithItsBasis()
      A commit that drops a paper is undone from its own backup, and the paper comes back with the basis it had. The basis is how a later reader judges the link — a restore that returns the fact without it hands back something weaker than what was taken away.
    • testTheReportHandsBackTheTokenForTheNextCommit

      @Test public void testTheReportHandsBackTheTokenForTheNextCommit()
      A client that has just committed knows the state — its own write produced it — so it should be able to keep editing and commit again. The report hands back the token for that. Without it the only way to learn the new baseline is to re-read the dataset, and a client that skips the re-read 409s on a change it made itself.
    • testAgentDraftsForTwoCuratorsDoNotCollapseOntoOneRow

      @Test public void testAgentDraftsForTwoCuratorsDoNotCollapseOntoOneRow()
      The bug this gate exists for. A DRAFT's run id is "draft-{curator}" and that run id sits inside UNIQUE(investigation, role, runId) — so when the agent relays two curators' drafts without naming them, both key to the agent's own identity, become one row, and the second autosave overwrites the first with no error at any layer. Asserted as two surviving rows with the right owners rather than as a status code, because the failure mode is a successful-looking 200.
    • testAgentReadsTheNamedCuratorsDraft

      @Test public void testAgentReadsTheNamedCuratorsDraft()
      Reading is delegated for the same reason writing is: an agent that asks for "the draft" without naming a curator gets its own, which is never what it means.
    • testAPlainCuratorMayNotWriteAsSomeoneElse

      @Test public void testAPlainCuratorMayNotWriteAsSomeoneElse()
      A plain curator claiming another identity is refused rather than silently rewritten to their own — storing a different fact from the one they asked for is worse than declining.
    • testAgentAndCuratorVerdictsCoexistWithTheRightKinds

      @Test public void testAgentAndCuratorVerdictsCoexistWithTheRightKinds()
      The agent's own verdict and a curator's relayed one coexist on the same set, and the curator's is recorded as CURATOR rather than as the agent that transmitted it. If the kind followed the transport instead of the delegation, "has a person looked at this" would answer false for every ruling a curator ever made -- which, now that curation is relayed, is all of them.
    • testSameJudgeRulingTwiceLeavesOneRow

      @Test public void testSameJudgeRulingTwiceLeavesOneRow()
      A judge has one standing ruling, so changing your mind edits rather than accumulates.
    • testPendingIsNotAVerdict

      @Test public void testPendingIsNotAVerdict()
      There is no `pending` verdict -- an un-ruled set has no row -- so a client sending one is told rather than silently given a verdict nobody chose.
    • testWithdrawReturnsTheSetToUntriaged

      @Test public void testWithdrawReturnsTheSetToUntriaged()
      Withdrawing returns the set to un-triaged, and withdrawing again is not an error -- a withdrawal that finds nothing has still achieved what it asked for.
    • testLockIsFreeThenHeldThenReleased

      @Test public void testLockIsFreeThenHeldThenReleased()
    • testSecondCuratorIsRefusedByNameThenMaySteal

      @Test public void testSecondCuratorIsRefusedByNameThenMaySteal()
      A second curator is refused with a 409 that NAMES the holder, then gets it with ?steal=true. "Someone else has it" without saying who leaves the curator with nobody to ask, which is why the holder is in the message rather than only in the GET.
    • testAgentRunningAsAdminStillRecordsItsOwnVerdictAsAgent

      @Test public void testAgentRunningAsAdminStillRecordsItsOwnVerdictAsAgent()
      The agent authenticates as whichever account runs it, which today is a human administrator. Inferring the judge kind from the transport therefore reports CURATOR for the agent's own verdicts, and reviewedByHuman then answers true for rulings no person made -- the exact failure JUDGE_KIND exists to prevent, inverted. So the caller declares it.
    • testUnknownJudgeKindIsRejected

      @Test public void testUnknownJudgeKindIsRejected()
      An unknown judgeKind is refused rather than silently defaulted to a judge nobody named.
    • testSavingADraftExtendsTheCuratorsLock

      @Test public void testSavingADraftExtendsTheCuratorsLock()
      Editing holds the lease. Without the refresh on the draft save, a curator working steadily for longer than the TTL loses the lock while still typing -- and finds out only when someone else takes it. Asserted as a moved expiry rather than by waiting out a real TTL.
    • testSavingADraftDoesNotTakeALockYouDoNotHold

      @Test public void testSavingADraftDoesNotTakeALockYouDoNotHold()
      A save by someone who holds no lock takes none -- a refresh must never become an acquire.
    • testSignWithoutTheLockIsRefused

      @Test public void testSignWithoutTheLockIsRefused()
      The whole gate: no lock, no sign, nothing written.
    • testSignWhileAnotherCuratorHoldsTheLockIsRefused

      @Test public void testSignWhileAnotherCuratorHoldsTheLockIsRefused()
      Someone else's lock is not yours. The refusal names the holder, as the lock endpoint's own 409 does: "you cannot sign" without saying who is in the way leaves the curator with nobody to ask.
    • testSignAppliesWhatAPlainCommitRefuses

      @Test public void testSignAppliesWhatAPlainCommitRefuses()
      The point of the endpoint. The same payload that a plain commit refuses goes through sign once the lock is held — with no ?force=true anywhere, because the signature is the consent.
    • testSignWithNoBodySignsTheDraft

      @Test public void testSignWithNoBodySignsTheDraft()
      With no body, what gets signed is the caller's own draft — the held-back delta.
    • testSignWithNoBodyAndNoDraftIsRejected

      @Test public void testSignWithNoBodyAndNoDraftIsRejected()
      ... and with neither a body nor a draft, there is nothing to sign. Say so rather than reporting success.
    • testAFailedSignKeepsTheLock

      @Test public void testAFailedSignKeepsTheLock()
      A sign that fails keeps the lock. The curator's next move is to re-read and sign again, and taking their lock away mid-refusal would make them re-acquire it — or find someone else had.
    • testADryRunSignDoesNotReleaseTheLock

      @Test public void testADryRunSignDoesNotReleaseTheLock()
      A dry run predicts; it must not release anything.
    • testCommitWhileAnotherCuratorHoldsTheLockIsRefused

      @Test public void testCommitWhileAnotherCuratorHoldsTheLockIsRefused()
      The gate: someone else holds it, so the write is refused and names them.
    • testCommitOnAnUnheldDatasetIsNotGated

      @Test public void testCommitOnAnUnheldDatasetIsNotGated()
      🛑 The non-breaking guarantee. Nobody holds the lock, so the commit behaves exactly as it did before the gate existed. If this ever fails, every client that commits without acquiring — which today is all of them — is broken.
    • testCommitByTheLockHolderIsNotGated

      @Test public void testCommitByTheLockHolderIsNotGated()
      Holding it yourself is not being blocked by it.
    • testPreflightIsNotGatedByAnotherCuratorsLock

      @Test public void testPreflightIsNotGatedByAnotherCuratorsLock()
      A preflight writes nothing, so it is deliberately exempt. Refusing it would stop a curator finding out what a commit WOULD do while somebody else holds the lock — which is exactly the moment they most want to know.
    • testBulkLockReadReportsWhoHoldsEachDataset

      @Test public void testBulkLockReadReportsWhoHoldsEachDataset()
      The read uib asked for: the curation queue pages up to 1000 rows and needs one request per screen, not one per row.
    • testTheLockSaysWhatIsHoldingItNotJustWho

      @Test public void testTheLockSaysWhatIsHoldingItNotJustWho()
      uib's ask: a blocked curator gets a name and has to choose wait-or-steal, and a batch mid-run looks exactly like a person at lunch. `?runId=` names the job, and it comes back on the read.

      🛑 Recorded on the LOCK rather than joined from the holder's draft, because a batch takes its locks BEFORE doing the work — at the moment a curator is blocked there may be no draft to join to.

    • testAHumanHeldLockNamesNoJob

      @Test public void testAHumanHeldLockNamesNoJob()
      A person takes a lock without naming a job, and it reads as a person: no run id.
    • testBulkLockReadAcceptsAlargeListInAbody

      @Test public void testBulkLockReadAcceptsAlargeListInAbody()
      The same answer with the ids in a body. Exists because a thousand ids is ~7 KB of query string against an 8 KB container header limit, so the queue's largest page sits on the boundary — and past it the container refuses the request with a 400 that never mentions datasets.
    • testBulkLockReadOmitsDatasetsNobodyHolds

      @Test public void testBulkLockReadOmitsDatasetsNobodyHolds()
      🛑 An unheld dataset is ABSENT from the map rather than present with locked:false. A queue painting 1000 rows should not be sent 1000 entries to say nothing is happening.
    • testBulkLockReportsPerDatasetRatherThanFailingTheBatch

      @Test public void testBulkLockReportsPerDatasetRatherThanFailingTheBatch()
      The batch never fails as a unit. One dataset held by someone else must not sink the claim over the rest, so each id carries its own outcome and the incumbent is named.
    • testBulkLockGrantsAFreeDatasetAndTheClaimIsVisible

      @Test public void testBulkLockGrantsAFreeDatasetAndTheClaimIsVisible()
      A free dataset is granted, and the claim is then visible to the single-dataset read.
    • testBulkReleaseDropsTheCallersOwnClaims

      @Test public void testBulkReleaseDropsTheCallersOwnClaims()
      The companion a finished run has to call. Without it a completed batch's claims sit until the lease lapses, gating curators on datasets nothing is working on any more.