Class DatasetsCurationCommitRestTest
- All Implemented Interfaces:
org.springframework.beans.factory.Aware, org.springframework.context.ApplicationContextAware
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 Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionvoidvoidvoidA dry run predicts; it must not release anything.voidA sign that fails keeps the lock.voidThe 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.voidThe bug this gate exists for.voidReading 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.voidThe agent authenticates as whichever account runs it, which today is a human administrator.voidA person takes a lock without naming a job, and it reads as a person: no run id.voidAn EMPTY evidence array means "the row should have none", so it CLEARS.voidA commit that changes nothing displaced nothing, so it keeps nothing.voidA 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.voidThe baseline-relevance hint round-trips, and a commit that carries ONLY the hint is applied.voidA free dataset is granted, and the claim is then visible to the single-dataset read.voidThe same answer with the ids in a body.void🛑 An unheld dataset is ABSENT from the map rather than present withlocked:false.voidThe read uib asked for: the curation queue pages up to 1000 rows and needs one request per screen, not one per row.voidThe batch never fails as a unit.voidThe companion a finished run has to call.voidvoidHolding it yourself is not being blocked by it.voidW14 — a continuous factor value keeps its measurement AND its unit.voidvoidW3 — deleting a continuous factor takes its measurement-bearing factor values with it.voidW1 — a factor is re-categorized by replacing it wholesale: the old factor goes out viadeletedIdsand a new one arrives withclientRef, in ONE commit.voidvoidW11 — a factor description-only edit reaches the database.voidThe restore point nobody had to remember to ask for.voidW6 — 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.voidA COMMIT may only name a PROPOSAL as the thing it applied.void🛑 The non-breaking guarantee.voidProvenance at the FACTOR and FACTOR VALUE levels survives the commit.voidW13 — a curated statement's justification survives the real persist.voidThe tags section had no transaction-boundary coverage for either provenance slot: the only guard wastestCommitPersistsStatementSupportingEvidence(), on design statements.voidW8 / 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.voidThe evidence a commit is given is recorded, not dropped.voidA statementdeletedIdsentry naming a row that is not on that factor value is refused rather than absorbed.voidW4 — one factor value is re-termed in place and every sibling is left alone.voidvoidA change that would strand a subset on deleted factor values needs the same explicit consent as the analysis cascade: 409 withoutforce, applied with it.voidvoidThe gate: someone else holds it, so the write is refused and names them.voidOmitting the code leaves the add path'sICin place — the behaviour every tag on this route has had.voidProvenance is sparse on purpose: an ordinary curator commit names no run and mints nothing.voidNaming a run mints a COMMIT annotation set carrying that run's build, and the report points at it.voidvoidThe dry run refuses it too, so a client can find out its document is stale without writing anything.voidvoidforce=trueis not a bypass.voidKeeping the displaced curation has to be free in audit events.voidvoidNaming a run must not cost an extra audit event.voidA no-op commit that named a run still mints.voidA commit that omitssupportingEvidenceCLEARS it — the design section is full-record replacement.voidThere 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.voidvoidA preflight must not audit.voidA preflight writes nothing, so it is deliberately exempt.voidA preflight writes nothing, so there is nothing to keep and no row left behind to explain.void🛑 A commit with no design section reportsdesignReportABSENT, not empty.voidA preflight REPORTS the force gate it deliberately does not trip.voidA preflight writes nothing, so it mints no row either — the run reference is only shape-checked.voidThe identity caveat, pinned as behaviour rather than left in a doc comment.voidA commit that drops a paper is undone from its own backup, and the paper comes back with the basis it had.voidA restore has to undo additions too, not just edits.voidOnly a SNAPSHOT can be restored — replaying some other tool's payload shape as a commit is a 400.voidRestoring an unchanged dataset changes nothing: the snapshot round-trips as a no-op.voidProvenance with no run to hang it off cannot be stored.voidA judge has one standing ruling, so changing your mind edits rather than accumulates.voidOne run committing the same dataset twice keeps one row.voidA save by someone who holds no lock takes none -- a refresh must never become an acquire.voidEditing holds the lease.voidA second curator is refused with a 409 that NAMES the holder, then gets it with ?steal=true.voidThe point of the endpoint.voidSomeone else's lock is not yours.void...voidWith no body, what gets signed is the caller's own draft — the held-back delta.voidThe whole gate: no lock, no sign, nothing written.voidThe basic loop: snapshot, relabel a factor value, then restore and find the original label back.voidThe subset-relevance hint round-trips, and a commit that carries ONLY the hint is applied.voidA backup must not modify what it backs up.voiduib'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.voidA client that has just committed knows the state — its own write produced it — so it should be able to keep editing and commit again.voidAn unknown judgeKind is refused rather than silently defaulted to a judge nobody named.voidWithdrawing 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.Methods inherited from class BaseJerseyIntegrationTest5
setUpAuthentication, tearDownSecurityContextMethods inherited from class BaseJerseyTest5
configure, configureClient, getTestContainerFactory, setApplicationContext, setUp, tearDownMethods inherited from class org.glassfish.jersey.test.JerseyTest
client, close, closeIfNotNull, configureDeployment, disable, enable, forceDisable, forceEnable, forceSet, getAsyncTimeoutMultiplier, getBaseUri, getClient, getLastLoggedRecord, getLoggedRecords, getPort, getSslContext, getSslParameters, isEnabled, set, set, setClient, target, target
-
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:
supportingEvidenceexisted onTagCommit,StatementCommitandSampleCharacteristicCommit, 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
keptFactorMetadataEditslearned about the two fields,isNoOpDesignApplywould 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 mirrorsbaselineRelevancedeliberately — same open vocabulary, same null-leaves / empty-clears convention, samekeptFactorMetadataEditsblind spot if the two clauses are missing: recommending a subset factor moves no structural counter, soisNoOpDesignApplywould 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
subsetFactorValueand 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 omitssupportingEvidenceCLEARS 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 wastestCommitPersistsStatementSupportingEvidence(), on design statements. A mapper that acceptedsupportingEvidenceand 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'sICin 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 viadeletedIdsand a new one arrives withclientRef, 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 statementdeletedIdsentry naming a row that is not on that factor value is refused rather than absorbed. It used to answer 200 withdeleted: 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.unitdoes 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 carryMeasurementrows 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 withoutforce, 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 indeletedIdsby 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=trueis 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=truecannot 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_USERholding ACL edit is entitled to make —commitCurationis@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 waytestCommitStrandingASubsetRequiresForce()does it: the seeded experiment carries no differential-expression analyses, so stranding a subset is the only half ofrequiresForce()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 —
BaseJerseyIntegrationTest5authenticates in a@BeforeEach— so without switching identity inside the test body the admin branch is the only one ever exercised andforce=truewould look unconditional. (@WithMockUserdoes not work here: the base@BeforeEachoverwrites 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_FORCEimmediately 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 reportsdesignReportABSENT, 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.lastUpdatedto the event date (AbstractCuratableDao#updateCurationDetailsFromAuditEvent, unconditionally, for every event type). So anAnnotationSetEventemitted when a snapshot is captured makes the dataset look edited when nothing about it changed.That is not cosmetic.
lastUpdatedis 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
lastUpdatedto 79 ms after the snapshot'screatedAt. The AnnotationSet row already records that a backup was taken, with its owncreatedAt,createdByandrunId— 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; anAnnotationSetEventfor the COMMIT row on top would be one more, saying nothing the section events did not — soAnnotationSetServiceImpl#ATTACH_AUDIT_WHENsuppresses 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 setscurationDetails.lastUpdated, andAnnotationSetEvent/DesignChangeEventare 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 (
previewDesignChangeis@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
valuehas no URI and nofreeTextIntendedis refused asUNGROUNDED_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 setscurationDetails.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.PublicationEntrycarries 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 insideUNIQUE(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=trueanywhere, 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 withlocked: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.
-