Skip to content

feat(experiments): allow changing the page of a draft experiment (#37176) - #37206

Merged
oidacra merged 14 commits into
mainfrom
issue-37176-draft-experiment-page-change
Aug 26, 2026
Merged

feat(experiments): allow changing the page of a draft experiment (#37176)#37206
oidacra merged 14 commits into
mainfrom
issue-37176-draft-experiment-page-change

Conversation

@oidacra

@oidacra oidacra commented Aug 25, 2026

Copy link
Copy Markdown
Member

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 pageId was
accepted 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

File
ExperimentsAPIImpl The eligibility rule, and regeneration of the control variant's url from the new page
ExperimentsFactoryImpl page_id added to UPDATE_EXPERIMENT
ExperimentsResource Carries the submitted pageId across

A pageId equal to the stored one stays a no-op whatever the status or variant count. Anything else
that 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 patchExperiment never reading pageId. True, but only half of it.
UPDATE_EXPERIMENT did not include page_id at all — the column appeared only in the INSERT, so the
page 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, shouldChangeThePage still
returned the old page while shouldRegenerateControlVariantUrl passed. traffic_proportion is in the
UPDATE statement and page_id was 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

  • @freddyDOTCMS — "move these validations to the API level": done, and it was more than a layering
    preference. The rule and the url regeneration were split across two layers, leaving save()
    half-guarded: a caller reaching experimentsAPI.save() directly with a changed pageId on a
    non-draft experiment could move page_id while 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, start and end pay nothing.

Tests

8 integration tests in a new ExperimentsResourceIntegrationTest, covering every value of the status
enum — 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:

  • the unchanged-pageId no-op on ineligible experiments — the compatibility guarantee that keeps
    clients 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;
  • SCHEDULED / ENDED / ARCHIVED — RUNNING was the only non-DRAFT status covered, leaving three
    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:

Verification status

  • Integration: 8/8 green locally, against the committed code.
  • Postman: inconclusive locally — the collection aborted on an ECONNRESET in an earlier folder, so
    the two new requests never ran. No dotCMS-side exception at the abort point; not yet attributed.
    PR Test / Postman Tests - Experiment in CI is the cheap comparison.
  • Draft on purpose until CI is green and the frontend half lands.

This PR fixes: #37176

This PR fixes: #37176

Spec for #37176: allow repointing a draft experiment at a different page
while the control is its only variant, and refuse every other page change
instead of discarding it silently.

Refs #37176
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

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @oidacra's task in 1m 52s —— View job


Code Review

  • Read the diff against origin/main
  • Review Java changes (API, Factory, Resource)
  • Review tests + Postman
  • Post review findings

Reviewed the backend page-change logic across ExperimentsAPIImpl, ExperimentsFactoryImpl, and ExperimentsResource. The design is sound: the eligibility rule and control-URL regeneration now live together in save() so they hold for every caller, and the page_id column is now written by UPDATE_EXPERIMENT. I traced the guard ordering and confirmed the important properties below.

Verified correct:

  • Permission on the new pagevalidatePermissionToEdit(experiment, user) (ExperimentsAPIImpl.java:183) runs against the incoming experiment, i.e. the new pageId, so a user cannot repoint an experiment onto a page they lack EDIT on. No permission bypass.
  • New page existencegetHtmlPageAsset(experiment) (:177) rejects a missing/blank pageId with DotStateException before the change rule runs, so an empty-string pageId from the form is refused rather than persisted.
  • No-op ordering — the !pageId.equals(...) gate (:214-215) runs before validatePageChange, so clients echoing back an unchanged pageId on a normal save never hit the DRAFT/variant checks. This matches the intent and is covered by the dedicated no-op test.
  • Control-URL formatregenerateControlVariantUrl (:1075) builds getURI() + "?variantName=" + DEFAULT_VARIANT.name(), byte-identical to createExperimentVariant (:1113), and only rewrites the control variant.
  • UPDATE parameter alignment — the added dc.addParam(experiment.pageId()) (ExperimentsFactoryImpl.java:166) is prepended to match the new leading page_id=?; the parameter order still lines up with the statement.

New Issues

  • 🟡 Medium: ExperimentsAPIImpl.java:1071regenerateControlVariantUrl re-fetches the page via findContentletByIdentifierAnyLanguage(experiment.pageId(), false) even though save() already loaded htmlPageAsset for the same pageId at line 177. This is a second content lookup inside the transaction on every page change. It is not a bug — the 2-arg fetch here intentionally mirrors createExperimentVariant, and getURI() should be identical — so this is optional. If you consolidate, verify the URI is unaffected by the live/variant fetch difference between the two call sites before reusing the existing asset. Fix this →

No blocking issues. The page_id write in UPDATE_EXPERIMENT is the change most worth a second reviewer's eye (as the author notes), but it is safe: every pre-existing save path writes back the pageId it already read, since the resource only overrides pageId when the form field is non-null and the API no-ops on an unchanged value.

· issue-37176-draft-experiment-page-change

@oidacra

oidacra commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

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.

The branch rename was reverted so #37180 could be reopened with its review
discussion intact, so the field names that branch again.

Refs #37176
@oidacra
oidacra deleted the issue-37176-draft-experiment-page-change branch August 25, 2026 14:59
Carries #37176 in the branch name so the PR links to its issue without a
closing keyword, which would close the issue when the spec PR merges —
before the implementation exists.

Refs #37176
@oidacra oidacra reopened this Aug 25, 2026


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
@oidacra oidacra changed the title docs(experiments): spec for changing the page of a draft experiment (#37176) feat(experiments): allow changing the page of a draft experiment (#37176) Aug 25, 2026
@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label Aug 25, 2026
…#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
@oidacra
oidacra marked this pull request as ready for review August 25, 2026 19:02
Comment thread dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentsResource.java Outdated
Comment thread specs/37176-draft-experiment-page-change/data-model.md
…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
@oidacra
oidacra added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit f2a25e9 Aug 26, 2026
69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Allow changing the page of a draft experiment that has no variants

2 participants