feat(experiments): allow changing the page of a draft experiment (#37176) - #37206
Conversation
Machine-local pointer to the active feature dir, rewritten by every /speckit-specify run. Spec-Kit 0.16.1 ships this rule in a managed .specify/.gitignore; this repo is pinned to 0.12.4.
- Feature Branch now carries the issue number, matching the branch rename to issue-37176-draft-experiment-page-change. - Justify why SCHEDULED is excluded rather than merely asserting it: start() clears an experiment's page for schedule conflicts against the page it had at that moment, so a scheduled experiment that changes page carries a stale clearance. DRAFT is the last state in which the page is still un-cleared, which is what makes it the boundary and what lets FR-007 skip re-validation. Refs #37176
- Make the delete-variants-then-change-page flow explicitly in scope. Two reviewers read it as ambiguous; eligibility is evaluated against current state, never history, and FR-009's message exists to send the user down exactly that path. Added as an edge case and as a US3 acceptance scenario. - Strengthen the SCHEDULED rationale: startScheduled() re-checks only permissions and status when the quartz job fires, not variants, goals or page conflicts. A scheduled experiment is a promise already validated against the state it had when scheduled, so editing its page afterwards has nothing downstream to catch it. Refs #37176
|
Claude finished @oidacra's task in 1m 52s —— View job Code Review
Reviewed the backend page-change logic across Verified correct:
New Issues
No blocking issues. The · |
|
Closing — #37180 is back open and is the PR to use. This one only existed because renaming the branch orphaned #37180's head ref and GitHub closed it. Restoring the old ref made it reopenable, so the review discussion (8 comments across 3 threads from @erickgonzalez and @freddyDOTCMS) stays where it happened, along with the full history. Nothing is lost here: both PRs pointed at the same commits, and #37180 already carries them. |
Field-level ground truth the spec deliberately stays above: the eligibility predicate against the real Immutables shapes, the control variant's url format taken verbatim from createExperimentVariant, ExperimentForm's absent sentinels, and the before/after table for the PATCH contract. Refs #37176
Red phase — no implementation yet, so these fail on purpose. Integration (7 tests, new ExperimentsResourceIntegrationTest): the rule lives in ExperimentsResource's private patchExperiment, and the API layer never sees the incoming form, so the tests drive the resource directly. Postman (2 requests): calling the resource method directly bypasses the JAX-RS exception mappers, so integration can assert IllegalArgumentException but not HTTP 400 — which the issue requires. Placed in the Start Experiment folder to reuse the DRAFT-with-variant and RUNNING states it already builds. Refs #37176
…#37176) RUNNING was the only non-DRAFT status covered, but FR-002 permits the change in DRAFT alone — so SCHEDULED, ENDED and ARCHIVED were unguarded. SCHEDULED is the one that matters: #37214 proposes allowing it later, and this test is what will make that change announce itself rather than slip through. Refs #37176
…re (#37176) Experiments are license-gated end to end — save, start, cancel and archive all go through hasValidLicense() — so without a license every test in this class dies with InvalidLicenseException rather than on its own assertion. LicenseTestUtil.getLicense() installs the trial license the repository already ships, the way PublisherAPITest does. The class no longer depends on a license being present on the machine, unlike ExperimentAPIImpIntegrationTest. Note the harness still needs DOT_DOTCMS_LICENSE set to any non-empty value, or a ~/.dotcms/license directory: dotcms-integration/ant-tasks.xml aborts the build before any test runs when neither exists. Refs #37176
) A submitted pageId was accepted by the request contract and then discarded. It is now applied when the experiment is a DRAFT whose only variant is the control, and refused with HTTP 400 otherwise — naming which rule blocked it. Silently dropping it was the more dangerous half of the defect: the caller was told its change had succeeded when it had not. ExperimentsResource: the eligibility rule. An unchanged pageId short-circuits before the checks, so clients that echo the whole experiment back on every save are unaffected. The control is matched by its DEFAULT id rather than its Original description, because the id is what addVariant keys off when deciding whether to copy the page's layout. ExperimentsAPIImpl.save(): regenerates the control variant's url when the page changes. The url is a snapshot taken at variant creation, so without this the copy-preview-URL action keeps returning a link to the previous page. ExperimentsFactoryImpl: page_id was missing from UPDATE_EXPERIMENT. It appeared only in the INSERT, so the page was immutable at the persistence layer — the actual root cause. The issue attributed the bug solely to patchExperiment not reading the field; that was only half of it. Refs #37176
#37176) The integration suites enumerate their classes explicitly via @SuiteClasses, and ExperimentsResourceIntegrationTest was in none of them. The 8 tests passed locally and would never have run in the pipeline — a regression in the page change rule would have merged without a signal. Added to MainSuite1a alongside ExperimentAPIImpIntegrationTest, which covers the same feature area. Refs #37176
…he REST layer @freddyDOTCMS asked for this on #37206 and the automated review reached the same place independently. The rule and the control-url regeneration were split across two layers: the resource validated, save() regenerated. That left save() half-guarded — a caller reaching experimentsAPI.save() directly with a changed pageId on a non-draft experiment, or one carrying real variants, would move page_id while leaving those variants' urls pointing at the old page. The rule now sits next to the regeneration it protects, so both run for every caller. Only a changed pageId reaches the check, so saves that keep their page (addVariant, deleteVariant, promoteVariant, start, end) pay nothing. The spec is unaffected: FR-002 never said which layer enforces the rule. If anything this is closer to it, since the previous version let a non-REST caller violate FR-002 outright. All 8 integration tests still pass — they assert behaviour, not placement. Refs #37176
Backend for #37176 — epic #36763 (Experiments: A/B Testing v2).
The problem
The page an experiment tests was frozen the moment the draft was created. A submitted
pageIdwasaccepted by the request contract and then discarded, so an editor who picked the wrong page had one
remedy: delete the experiment and rebuild it — name, description, goals and traffic split included.
The restriction was broader than the data required. The real constraint is variants: creating one
copies the page's layout into it, so the copy only means anything for the page it came from. The
control is exempt — it owns no duplicated layout, it is the page. So the swap is safe in exactly
one case: still a draft, and the control is the only variant.
What changed
ExperimentsAPIImplurlfrom the new pageExperimentsFactoryImplpage_idadded toUPDATE_EXPERIMENTExperimentsResourcepageIdacrossA
pageIdequal to the stored one stays a no-op whatever the status or variant count. Anything elsethat does not qualify is refused with HTTP 400 naming the rule that blocked it, rather than being
dropped in silence — that silent drop was the more dangerous half of the defect, because the caller
was told its change had succeeded when it had not.
The root cause was not where the issue said it was
The issue attributed the bug to
patchExperimentnever readingpageId. True, but only half of it.UPDATE_EXPERIMENTdid not includepage_idat all — the column appeared only in the INSERT, so thepage was immutable at the persistence layer. Implemented exactly as written, the fix would not
have worked.
It surfaced as a split result: with the resource and API changes in place,
shouldChangeThePagestillreturned the old page while
shouldRegenerateControlVariantUrlpassed.traffic_proportionis in theUPDATE statement and
page_idwas not, so the new control url persisted and the new page did not.Worth a careful look in review: this writes a column that the UPDATE never wrote before. It is
safe — every existing path writes back the value it already had — but it deserves a second pair of
eyes.
Review feedback
preference. The rule and the url regeneration were split across two layers, leaving
save()half-guarded: a caller reaching
experimentsAPI.save()directly with a changedpageIdon anon-draft experiment could move
page_idwhile leaving non-control variants' urls on the old page.The rule now sits next to the regeneration it protects. Only a changed page reaches the check, so
addVariant,deleteVariant,promoteVariant,startandendpay nothing.Tests
8 integration tests in a new
ExperimentsResourceIntegrationTest, covering every value of the statusenum — DRAFT with only the control, DRAFT with a real variant, RUNNING, SCHEDULED, ENDED, ARCHIVED.
Two of them are not in the issue's list and were added deliberately:
pageIdno-op on ineligible experiments — the compatibility guarantee that keepsclients echoing the whole experiment back on every save working, and the reason the equality check
must run before the eligibility check rather than after it;
quarters of the status rule unguarded. SCHEDULED matters most: Allow changing the page of a SCHEDULED experiment that has not started #37214 proposes allowing it later,
and this is what will make that change announce itself.
The class also had to be registered in
MainSuite1a. The suites enumerate their classes explicitly,so without that the 8 tests would have passed locally and never run in CI — a regression could
have merged with no signal.
Postman gains two requests asserting the 400 itself. Driving the resource directly bypasses the
JAX-RS exception mappers, so integration tests can assert the exception type but not the status code.
Not in this PR
The frontend half (FR-008–013, User Stories 1, 3, 4) lands in
#37135, whose branch already carries the files this
change would need. Until it ships the capability is not reachable by a user — the page picker
stays disabled by #37064's blanket rule — so this should not be demoed as "editors can now change the
page". It is verified by tests, not by clicking.
Also surfaced and filed separately rather than fixed here:
is validated per transition
ExperimentsAPIImpl(save()keys off theOriginaldescription,addVariant()off theDEFAULTid). This change uses the id, because thatis what gates the layout copy. Unifying them would change behaviour for existing experiments.
Verification status
ECONNRESETin an earlier folder, sothe two new requests never ran. No dotCMS-side exception at the abort point; not yet attributed.
PR Test / Postman Tests - Experimentin CI is the cheap comparison.This PR fixes: #37176
This PR fixes: #37176