diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolver.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolver.java new file mode 100644 index 000000000000..5e83af46cd1e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolver.java @@ -0,0 +1,124 @@ +package com.dotcms.rest.api.v1.experiments; + +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.NoSuchUserException; +import com.dotmarketing.business.UserAPI; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.User; +import java.util.function.Supplier; + +/** + * Turns an {@code Experiment.createdBy()} user ID into the display name published as + * {@code createdByUserName} (#37304). + * + *

The field is never null, absent or empty. The system user is reported as + * {@code "System"}, and everything that cannot be resolved to a named user — a deleted user, an + * orphaned reference, a user with no name set, or a lookup that blows up — is reported as + * {@code "unknown"}. + * + *

The two labels are borrowed from {@link com.dotcms.browser.BrowserAPIImpl}'s {@code ownerName}, + * which answers the same question for the Content Drive folder view: two listings in the same + * product labelling the same orphaned owner differently is a worse outcome than either label on its + * own. The alignment is exact for the deleted/orphaned and system-user cases and deliberately + * stricter in two others: Content Drive publishes the raw {@code getFullName()} for a blank-named + * user (a bare space) and {@code null} for an unset id, where this resolver reports + * {@code "unknown"} so the field is never blank. Content Drive also reaches the user through + * {@link com.liferay.portal.ejb.UserLocalManagerUtil} and so bypasses {@code UserCache}; this one + * deliberately does not. + * + *

No checked or runtime exception may escape. The caller is a serializer running inside + * an already-successful API response, so an exception escaping this class would turn a working + * experiment read into a failed request over a field that is only decoration. {@code Error} and its + * subclasses are deliberately not caught. + * + *

Cost. {@code UserFactoryImpl.loadUserById} consults {@code UserCache} before the + * database, so a listing whose experiments share a resolvable creator costs one database + * read for the first row and an in-memory hit for every row after it. An unresolvable + * creator is the exception worth knowing about: a miss is not negative-cached, so every row + * referencing an orphaned id pays its own query and its own {@code NoSuchUserException}. The same + * residual limitation is documented for Content Drive on + * {@code BrowserAPIImpl#warmUpUserCache}. This class deliberately adds no memo of its own: the + * value is resolved per serialization so that a user who renames themselves is reported under the + * new name. + */ +public class ExperimentCreatorNameResolver { + + /** Shown for an owner that cannot be resolved to a named user. Matches Content Drive. */ + static final String UNKNOWN = "unknown"; + + /** Shown for the system user. Matches Content Drive. */ + static final String SYSTEM = "System"; + + /** One warning per minute when the user layer is failing, rather than one per experiment. */ + private static final int WARN_THROTTLE_MILLIS = 60_000; + + /** + * The instance used by the model. The {@link UserAPI} is supplied lazily rather than captured, + * so class initialization never depends on {@code APILocator} being ready. + */ + public static final ExperimentCreatorNameResolver INSTANCE = + new ExperimentCreatorNameResolver(APILocator::getUserAPI); + + private final Supplier userAPI; + + /** + * Visible for testing: lets a test drive every fallback branch with a mocked {@link UserAPI} + * and no database. + * + * @param userAPI supplies the user layer when a lookup is actually needed + */ + ExperimentCreatorNameResolver(final Supplier userAPI) { + this.userAPI = userAPI; + } + + /** + * Resolves a creator's display name. + * + * @param createdById the experiment's {@code createdBy} user ID + * @return the creator's full name, trimmed; {@code "System"} for the system user; + * {@code "unknown"} when the ID is unset, resolves to nobody, resolves to a user with no name + * set, or the lookup itself fails. Never null, never blank. + */ + public String resolve(final String createdById) { + if (!UtilMethods.isSet(createdById)) { + return UNKNOWN; + } + + if (UserAPI.SYSTEM_USER_ID.equalsIgnoreCase(createdById)) { + return SYSTEM; + } + + try { + final User creator = this.userAPI.get().loadUserById(createdById); + // getFullName() joins the parts with spaces and never trims, so a user with only a + // first name yields "Admin ". Trim before publishing: the padding would be visible in + // the portlet column, and an all-blank name must collapse to the fallback. + final String fullName = null != creator ? creator.getFullName() : null; + + if (UtilMethods.isSet(fullName)) { + return fullName.trim(); + } + + Logger.debug(this, () -> String.format( + "Experiment creator '%s' resolves to a User with no name set; reporting '%s'", + createdById, UNKNOWN)); + return UNKNOWN; + } catch (final NoSuchUserException e) { + // A deleted or orphaned creator is a data condition, not an error: debug keeps a + // listing full of them from flooding the log while leaving the detail reachable. + Logger.debug(this, e, () -> String.format( + "Experiment creator '%s' no longer resolves to a User; reporting '%s'", + createdById, UNKNOWN)); + return UNKNOWN; + } catch (final Exception e) { + // Unlike the branch above, this one means something is actually wrong, and under a + // failing user layer it fires once per row. Throttle it and keep the stack trace: + // without the throwable a NullPointerException logs as "...: null" and is undiagnosable. + Logger.warnEveryAndDebug(ExperimentCreatorNameResolver.class, String.format( + "Failed to resolve the name of Experiment creator '%s'", createdById), + e, WARN_THROTTLE_MILLIS); + return UNKNOWN; + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentView.java new file mode 100644 index 000000000000..250003372213 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentView.java @@ -0,0 +1,63 @@ +package com.dotcms.rest.api.v1.experiments; + +import com.dotcms.experiments.model.Experiment; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * REST view of an {@link Experiment}: the experiment exactly as it is persisted, plus the display + * name of the user who created it. + * + *

The derivation lives here rather than on the model on purpose. {@link Experiment} is a data + * object, and anything computed on it is computed everywhere it is serialized — which is not only + * REST. Push-publish bundling and starter export serialize the whole Experiment too, and their + * receiver reads it back through a mapper that rejects unknown properties and rolls back the entire + * bundle when it finds one. Keeping the name in the view means only the endpoint response carries + * it, the model stays a plain POJO, and no other serialization path pays a user lookup. + * + *

The experiment is {@link JsonUnwrapped}, so the payload is the Experiment's own fields with + * {@code createdByUserName} beside them rather than nested — the wire contract consumers see is a + * flat object, unchanged except for the added field. + */ +public class ExperimentView { + + private final Experiment experiment; + + /** + * Wraps an Experiment for a REST response. + * + * @param experiment the experiment to publish + * @return a view that serializes the experiment plus its creator's display name + */ + public static ExperimentView of(final Experiment experiment) { + return new ExperimentView(experiment); + } + + private ExperimentView(final Experiment experiment) { + this.experiment = experiment; + } + + @JsonUnwrapped + public Experiment getExperiment() { + return this.experiment; + } + + /** + * The display name of the user behind the experiment's {@code createdBy}, resolved while the + * response is written and never stored, so a creator who renames themselves is reported under + * the new name. Reports {@code "System"} for the system user and {@code "unknown"} when the + * creator cannot be resolved or has no name set, matching the labels the Content Drive folder + * view already uses. Never null, never blank. + * + * @return the creator's display name + */ + @JsonProperty("createdByUserName") + @Schema(description = "Display name of the user who created the experiment. Reports \"System\" " + + "for the system user and \"unknown\" when the user cannot be resolved or has no name " + + "set, so the value is never empty.", + example = "Admin User") + public String getCreatedByUserName() { + return ExperimentCreatorNameResolver.INSTANCE.resolve(this.experiment.createdBy()); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java index d925663677a0..35c2ef74b986 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java @@ -3,9 +3,10 @@ import com.dotcms.experiments.model.Experiment; import com.dotcms.rest.ResponseEntityView; import java.util.List; +import java.util.stream.Collectors; -public class ResponseEntityExperimentView extends ResponseEntityView> { +public class ResponseEntityExperimentView extends ResponseEntityView> { public ResponseEntityExperimentView(final List entity) { - super(entity); + super(entity.stream().map(ExperimentView::of).collect(Collectors.toList())); } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntitySingleExperimentView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntitySingleExperimentView.java index 6129f74eeba5..5ac3fdeb60fe 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntitySingleExperimentView.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntitySingleExperimentView.java @@ -2,10 +2,9 @@ import com.dotcms.experiments.model.Experiment; import com.dotcms.rest.ResponseEntityView; -import java.util.List; -public class ResponseEntitySingleExperimentView extends ResponseEntityView { +public class ResponseEntitySingleExperimentView extends ResponseEntityView { public ResponseEntitySingleExperimentView(final Experiment entity) { - super(entity); + super(ExperimentView.of(entity)); } } diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 0abb4548e6e2..21690581e53b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -28380,6 +28380,59 @@ components: properties: description: type: string + ExperimentView: + type: object + properties: + createdBy: + type: string + createdByUserName: + type: string + description: "Display name of the user who created the experiment. Reports\ + \ \"System\" for the system user and \"unknown\" when the user cannot\ + \ be resolved or has no name set, so the value is never empty." + example: Admin User + creationDate: + type: string + format: date-time + description: + type: string + goals: + $ref: "#/components/schemas/Goals" + id: + type: string + lastModifiedBy: + type: string + lookBackWindowExpireTime: + type: integer + format: int64 + modDate: + type: string + format: date-time + name: + type: string + pageId: + type: string + runningIds: + $ref: "#/components/schemas/RunningIds" + scheduling: + $ref: "#/components/schemas/Scheduling" + status: + type: string + enum: + - RUNNING + - SCHEDULED + - ENDED + - DRAFT + - ARCHIVED + targetingConditions: + type: array + items: + $ref: "#/components/schemas/TargetingCondition" + trafficAllocation: + type: number + format: float + trafficProportion: + $ref: "#/components/schemas/TrafficProportion" ExportSecretForm: type: object properties: @@ -33903,7 +33956,7 @@ components: entity: type: array items: - $ref: "#/components/schemas/Experiment" + $ref: "#/components/schemas/ExperimentView" errors: type: array items: @@ -35503,7 +35556,7 @@ components: type: object properties: entity: - $ref: "#/components/schemas/Experiment" + $ref: "#/components/schemas/ExperimentView" errors: type: array items: diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolverTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolverTest.java new file mode 100644 index 000000000000..e2a4a40f32dd --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolverTest.java @@ -0,0 +1,190 @@ +package com.dotcms.rest.api.v1.experiments; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.dotmarketing.business.NoSuchUserException; +import com.dotmarketing.business.UserAPI; +import com.dotmarketing.exception.DotDataException; +import com.liferay.portal.model.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ExperimentCreatorNameResolver}, the rule that turns an Experiment's + * {@code createdBy} user ID into the display name returned as {@code createdByUserName} (#37304). + * + *

The whole value of this field is that the Experiments portlet's Created By column is + * never blank. That promise is not made by the happy path — it is made by the failure + * branches, so each one gets its own test: a user who no longer exists, a user who exists but has + * no name set, and an infrastructure failure. All three collapse to the same answer, + * {@code "unknown"}, and none of them may throw: a decoration failure must never turn a successful + * experiment read into a failed request (FR-009, FR-010, FR-011). + * + *

The labels deliberately match {@code BrowserAPIImpl.ownerName}, which answers the same question + * for the Content Drive folder view: {@code "System"} for the system user, {@code "unknown"} for + * anyone who cannot be resolved. Two listings in the same product should not label the same + * orphaned owner differently. + * + *

The resolver takes its {@link UserAPI} through the constructor precisely so these branches can + * be exercised without a database; production code reaches it through {@code APILocator}. + */ +class ExperimentCreatorNameResolverTest { + + private static final String CREATOR_ID = "dotcms.org.1"; + private static final String UNKNOWN = "unknown"; + + private UserAPI userAPI; + private ExperimentCreatorNameResolver resolver; + + @BeforeEach + void setUp() { + userAPI = mock(UserAPI.class); + resolver = new ExperimentCreatorNameResolver(() -> userAPI); + } + + private static User userNamed(final String first, final String middle, final String last) { + final User user = new User(); + user.setFirstName(first); + user.setMiddleName(middle); + user.setLastName(last); + return user; + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: The creator ID resolves to a User with a first and last name. + * ExpectedResult: The user's full name is returned. + */ + @Test + void resolve_userWithName_returnsFullName() throws Exception { + when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("Admin", "", "User")); + + assertEquals("Admin User", resolver.resolve(CREATOR_ID)); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: The creator resolves to a User whose first, middle and last name are all + * blank. {@code User.getFullName()} joins the blank parts with a space, so it + * returns {@code " "} — a single space, NOT the empty string. + * ExpectedResult: {@code "unknown"} — the field is never blank (FR-003, FR-010). Note what + * makes this work: {@code UtilMethods.isSet} trims before measuring length. + * A non-trimming emptiness check would let the space through to the column. + */ + @Test + void resolve_userWithBlankName_fallsBackToUnknown() throws Exception { + when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("", "", "")); + + assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID)); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: The creator ID points at a user that no longer exists — a deleted user, or an + * orphaned reference left behind by one. + * ExpectedResult: {@code "unknown"}, and no exception escapes (FR-009). + */ + @Test + void resolve_deletedUser_fallsBackToUnknown() throws Exception { + when(userAPI.loadUserById(CREATOR_ID)) + .thenThrow(new NoSuchUserException("No user matches " + CREATOR_ID)); + + assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID)); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: The user lookup fails for an infrastructure reason rather than a data one. + * ExpectedResult: {@code "unknown"}. This is the branch that keeps a database hiccup from + * turning {@code GET /v1/experiments} into a 500 (FR-011). + */ + @Test + void resolve_lookupFailure_fallsBackToUnknownAndDoesNotThrow() throws Exception { + when(userAPI.loadUserById(CREATOR_ID)).thenThrow(new DotDataException("boom")); + + assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID)); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: An unexpected unchecked failure escapes the user layer. + * ExpectedResult: Still {@code "unknown"}. The catch is deliberately broad because the caller + * is a serializer: anything thrown here would surface as a failed API response + * for a field that is only decoration. + */ + @Test + void resolve_unexpectedRuntimeFailure_fallsBackToUnknown() throws Exception { + when(userAPI.loadUserById(CREATOR_ID)).thenThrow(new IllegalStateException("unexpected")); + + assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID)); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: The creator has a first name but no last name (or the reverse). + * {@code User.getFullName()} concatenates unconditionally with a space and + * does not trim, so it hands back {@code "Admin "}. + * ExpectedResult: {@code "Admin"} — the padding must not reach the portlet's Created By + * column. This is the branch adjacent to the all-blank one, and the reason + * the resolver trims rather than trusting the source. + */ + @Test + void resolve_userWithOnlyAFirstName_returnsTheNameWithoutPadding() throws Exception { + when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("Admin", "", "")); + + assertEquals("Admin", resolver.resolve(CREATOR_ID)); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: The user layer returns {@code null} instead of throwing. The production + * {@code UserAPIImpl} throws {@link NoSuchUserException} rather than returning + * null, so this pins a defensive branch reachable through any other + * {@code UserAPI} implementation or decorator. + * ExpectedResult: {@code "unknown"}, with no NullPointerException. + */ + @Test + void resolve_nullUser_fallsBackToUnknown() throws Exception { + when(userAPI.loadUserById(CREATOR_ID)).thenReturn(null); + + assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID)); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: The experiment was created by the system user, whose ID is {@code "system"}. + * ExpectedResult: {@code "System"}, without consulting the user layer at all — the same + * short-circuit {@code BrowserAPIImpl.ownerName} applies, so the two listings + * agree on the label. + */ + @Test + void resolve_systemUser_returnsSystemWithoutLookup() throws Exception { + assertEquals("System", resolver.resolve("system")); + assertEquals("System", resolver.resolve("SYSTEM")); + + verify(userAPI, never()).loadUserById(anyString()); + } + + /** + * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)} + * Given Scenario: An unset creator ID. {@code AbstractExperiment.createdBy()} is a mandatory + * attribute, so the model cannot actually produce this — the test pins the + * defensive behaviour rather than a reachable path. + * ExpectedResult: {@code "unknown"}, and the user layer is never consulted, so a blank ID + * cannot cost a lookup or raise. + */ + @Test + void resolve_unsetId_returnsUnknownAndSkipsLookup() throws Exception { + assertEquals(UNKNOWN, resolver.resolve("")); + assertEquals(UNKNOWN, resolver.resolve(null)); + + verify(userAPI, never()).loadUserById(anyString()); + verifyNoMoreInteractions(userAPI); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentViewTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentViewTest.java new file mode 100644 index 000000000000..34b4a36fad15 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentViewTest.java @@ -0,0 +1,260 @@ +package com.dotcms.rest.api.v1.experiments; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.dotcms.experiments.model.Experiment; +import com.dotcms.publishing.BundlerUtil; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.UserAPI; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.liferay.portal.model.User; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Serialization tests for {@link ExperimentView}, the REST-layer wrapper that publishes + * {@code createdByUserName} beside an Experiment (#37304). + * + *

Why the field lives here and not on the model. An earlier revision of this work put the + * accessor on {@code AbstractExperiment} itself. That made every serialization of an Experiment + * resolve a user — including push-publish bundling and starter export, which are not REST responses + * at all, and which are read back by a receiver that rejects unknown properties and rolls back the + * whole bundle. Keeping the derivation in the view means the model stays a plain data object and + * only the endpoint response is affected, which is what review asked for. + * + *

The wire shape must not change as a result: {@code createdByUserName} is a sibling of the + * Experiment's own fields, not a nested object. {@link #view_flattensTheExperimentAlongsideTheName()} + * is what pins that. + */ +class ExperimentViewTest { + + private static final String CREATOR_ID = "dotcms.org.1"; + private static final String CREATOR_NAME = "Admin User"; + private static final String MODIFIER_ID = "dotcms.org.2"; + + private static Experiment anExperiment() { + return Experiment.builder() + .name("Homepage CTA test") + .pageId("2d8b8b1e-0000-0000-0000-000000000001") + .createdBy(CREATOR_ID) + .lastModifiedBy(MODIFIER_ID) + .id("0e8b8b1e-0000-0000-0000-000000000002") + .lookBackWindowExpireTime(1_800_000L) + .build(); + } + + private static User userNamed(final String first, final String last) { + final User user = new User(); + user.setFirstName(first); + user.setMiddleName(""); + user.setLastName(last); + return user; + } + + private static void withResolvableCreator(final ThrowingRunnable body) throws Exception { + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("Admin", "User")); + when(userAPI.loadUserById(MODIFIER_ID)).thenReturn(userNamed("Other", "Person")); + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + apiLocator.when(APILocator::getUserAPI).thenReturn(userAPI); + body.run(); + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } + + /** + * Method to test: {@link ExperimentView} + * Given Scenario: A view wrapping an Experiment whose creator resolves to a named user. + * ExpectedResult: The payload carries the Experiment's own fields at the top level AND + * {@code createdByUserName} beside them — the same shape the model-level + * revision produced, so the API contract does not move. + */ + @Test + void view_flattensTheExperimentAlongsideTheName() throws Exception { + withResolvableCreator(() -> { + final ObjectMapper mapper = DotObjectMapperProvider.createDefaultMapper(); + + final JsonNode payload = + mapper.readTree(mapper.writeValueAsString(ExperimentView.of(anExperiment()))); + + assertEquals(CREATOR_NAME, payload.get("createdByUserName").asText()); + assertEquals(CREATOR_ID, payload.get("createdBy").asText(), + "createdBy must still be a top-level field, not nested under the experiment"); + assertEquals("Homepage CTA test", payload.get("name").asText(), + "The Experiment's own fields must stay at the top level"); + assertFalse(payload.has("experiment"), + "The Experiment must be unwrapped, not nested under a property"); + }); + } + + /** + * Method to test: {@link ExperimentView} + * Given Scenario: The same Experiment serialized WITHOUT the view, as the push-publish bundler + * and starter export do. + * ExpectedResult: No {@code createdByUserName}, and the user layer is never consulted. This is + * the guarantee that moving the derivation to the REST layer buys, and it needs + * no mix-in in BundlerUtil to hold. + */ + @Test + void bareExperiment_carriesNoNameAndCostsNoLookup() throws Exception { + final UserAPI userAPI = mock(UserAPI.class); + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + apiLocator.when(APILocator::getUserAPI).thenReturn(userAPI); + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + BundlerUtil.objectToJSON(anExperiment(), out); + final String bundled = out.toString(StandardCharsets.UTF_8); + + assertFalse(bundled.contains("createdByUserName"), + "A bare Experiment must not carry the derived name"); + assertTrue(bundled.contains("createdBy"), + "Precondition: the bundled shape should still carry createdBy"); + verifyNoInteractions(userAPI); + } + } + + /** + * Method to test: {@link ExperimentView} + * Given Scenario: The view is built but never serialized. + * ExpectedResult: No user lookup. The name is resolved when the response is written, so the + * value cannot go stale and building a view costs nothing. + */ + @Test + void buildingTheViewWithoutSerializing_costsNoLookup() throws Exception { + final UserAPI userAPI = mock(UserAPI.class); + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + apiLocator.when(APILocator::getUserAPI).thenReturn(userAPI); + + ExperimentView.of(anExperiment()); + + verifyNoInteractions(userAPI); + } + } + + /** + * Method to test: {@link ExperimentView} + * Given Scenario: An Experiment whose creator cannot be resolved. + * ExpectedResult: {@code "unknown"} — the column is never blank, and the response still + * serializes (FR-003, FR-009). + */ + @Test + void view_withUnresolvableCreator_reportsUnknown() throws Exception { + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.loadUserById(anyString())) + .thenThrow(new com.dotmarketing.business.NoSuchUserException("gone")); + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + apiLocator.when(APILocator::getUserAPI).thenReturn(userAPI); + + final ObjectMapper mapper = DotObjectMapperProvider.createDefaultMapper(); + final JsonNode payload = + mapper.readTree(mapper.writeValueAsString(ExperimentView.of(anExperiment()))); + + assertEquals("unknown", payload.get("createdByUserName").asText()); + } + } + + /** + * Method to test: {@link ExperimentView} + * Given Scenario: The same view serialized twice, with the creator renaming in between. + * ExpectedResult: The second payload reports the new name. The value is resolved per + * serialization, so it cannot go stale (FR-016). + */ + @Test + void renamingTheCreator_isReflectedOnTheNextSerialization() throws Exception { + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.loadUserById(CREATOR_ID)) + .thenReturn(userNamed("Admin", "User"), userNamed("Renamed", "User")); + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + apiLocator.when(APILocator::getUserAPI).thenReturn(userAPI); + + final ObjectMapper mapper = DotObjectMapperProvider.createDefaultMapper(); + final ExperimentView view = ExperimentView.of(anExperiment()); + + final JsonNode before = mapper.readTree(mapper.writeValueAsString(view)); + final JsonNode after = mapper.readTree(mapper.writeValueAsString(view)); + + assertEquals(CREATOR_NAME, before.get("createdByUserName").asText()); + assertEquals("Renamed User", after.get("createdByUserName").asText(), + "The name must be resolved per serialization, never captured on the view"); + } + } + + /** + * Method to test: the Experiment model itself. + * Given Scenario: A bare Experiment round-tripped through the REST mapper. + * ExpectedResult: It parses back cleanly and keeps createdBy, getOwner() and lastModifiedBy. + * This is the guard that the model stayed a plain data object: with the derived + * field on the model, a serialize-only property made the payload unreadable by + * any strict reader, which is the whole reason the field moved to this view. + */ + @Test + void bareExperiment_stillRoundTripsAndKeepsItsContract() throws Exception { + // The APILocator mock is scaffolding, not subject: AbstractExperiment's pre-existing + // @Value.Derived getParentPermissionable() calls the ContentletAPI at build time, so an + // Experiment cannot be constructed in a bare unit-test JVM. That it fires on build is + // incidentally the very behaviour this feature avoided by not being a derived attribute. + final UserAPI userAPI = mock(UserAPI.class); + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + apiLocator.when(APILocator::getUserAPI).thenReturn(userAPI); + + final ObjectMapper mapper = DotObjectMapperProvider.createDefaultMapper(); + final Experiment experiment = anExperiment(); + + final String json = mapper.writeValueAsString(experiment); + final Experiment parsed = mapper.readValue(json, Experiment.class); + + assertFalse(json.contains("createdByUserName"), + "The model must not carry the derived field"); + assertEquals(CREATOR_ID, parsed.createdBy()); + assertEquals(CREATOR_ID, parsed.getOwner(), + "getOwner() must still resolve from createdBy"); + assertEquals(MODIFIER_ID, parsed.lastModifiedBy()); + verifyNoInteractions(userAPI); + } + } + + /** + * Method to test: {@link ExperimentView} + * Given Scenario: An Experiment whose creator resolves, serialized through the view. + * ExpectedResult: The name comes from {@code createdBy} and never from {@code lastModifiedBy}, + * and the modifier is never looked up. + */ + @Test + void view_resolvesTheCreatorAndNotTheModifier() throws Exception { + final UserAPI userAPI = mock(UserAPI.class); + when(userAPI.loadUserById(anyString())).thenReturn(userNamed("Admin", "User")); + + try (MockedStatic apiLocator = mockStatic(APILocator.class)) { + apiLocator.when(APILocator::getUserAPI).thenReturn(userAPI); + + DotObjectMapperProvider.createDefaultMapper() + .writeValueAsString(ExperimentView.of(anExperiment())); + + verify(userAPI).loadUserById(CREATOR_ID); + verify(userAPI, never()).loadUserById(MODIFIER_ID); + } + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentsResourceIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentsResourceIntegrationTest.java index ffc4f014381a..98ea4e1113ad 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentsResourceIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentsResourceIntegrationTest.java @@ -261,7 +261,8 @@ private Experiment patchPageId(final Experiment experiment, final String pageId) .build(); return resource.update(getHttpRequest(), response, experiment.id().orElseThrow(), form) - .getEntity(); + .getEntity() + .getExperiment(); } /** diff --git a/specs/37304-experiment-created-by-username/contracts/experiment-created-by-username.md b/specs/37304-experiment-created-by-username/contracts/experiment-created-by-username.md new file mode 100644 index 000000000000..d260e614d4f0 --- /dev/null +++ b/specs/37304-experiment-created-by-username/contracts/experiment-created-by-username.md @@ -0,0 +1,109 @@ +# Contract: `createdByUserName` on the Experiment payload + +**Feature**: [../spec.md](../spec.md) | **Plan**: [../plan.md](../plan.md) + +The contract is the `Experiment` schema itself, so it is inherited by every response that embeds an +Experiment. `openapi.yaml` is generated from the model annotations — this file states what the +regenerated yaml must contain, it is not a second source of truth. + +--- + +## Schema fragment (expected in `dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml`) + +Under `components.schemas.Experiment.properties`, alphabetically between `createdBy` and +`creationDate`: + +```yaml + Experiment: + type: object + properties: + createdBy: + type: string # unchanged + createdByUserName: + type: string + description: >- + Display name of the user who created the experiment. Reports "System" for the + system user and "unknown" when the user cannot be resolved or has no name set, + so the value is never empty. + example: Admin User + creationDate: + type: string + format: date-time +``` + +Constraints the generated yaml must satisfy: + +- `createdBy` keeps `type: string` and gains no description change (FR-005). +- No property is removed, renamed or retyped (FR-007). +- `owner`, `identifier`, `permissionId`, `manifestInfo` stay **absent** — they are `@JsonIgnore`d + derived members and must remain so (FR-006). +- `lastModifiedBy` is unchanged, with no name companion (FR-008). + +## Response wrappers (unchanged — listed to make the "no edit" explicit) + +| Wrapper | Shape | Change | +|---|---|---| +| `ResponseEntitySingleExperimentView` | `ResponseEntityView` | **None** — inherits the field | +| `ResponseEntityExperimentView` | `ResponseEntityView>` | **None** — inherits the field | + +## Endpoints that must carry the field + +All 14 inherit it without being edited. Enumerated in [../spec.md](../spec.md#endpoints-in-scope): +create, `PATCH`, `_archive`, `GET /{id}`, `GET` list, `DELETE /goals/primary`, `_start`, `_end`, +`scheduled/{id}/_cancel`, `POST /variants`, `DELETE /variants/{name}`, `PUT /variants/{name}`, +`PUT /variants/{name}/_promote`, `DELETE /targetingConditions/{id}`. + +Explicitly **not** carrying it, because they return no Experiment: +`DELETE /v1/experiments/{experimentId}` (returns the string `"Experiment deleted"`), +`POST /isUserIncluded`, `GET /{id}/results`, `GET /health`. + +## Behavioural contract + +| Condition | `createdBy` | `createdByUserName` | HTTP | +|---|---|---|---| +| Creator resolves, has a name | user id | full name (first + middle + last) | 200 | +| Creator resolves, all name parts blank | user id | `unknown` | 200 | +| Creator does not resolve (deleted/orphaned) | user id | `unknown` | 200 | +| User lookup fails (infrastructure) | user id | `unknown` | 200 | +| Creator is the system user | `system` | `System`, short-circuited before any lookup | 200 | +| One bad creator among many in a list | per entry | only the affected entry falls back | 200 | + +The two fallback labels are the ones `BrowserAPIImpl.ownerName` already publishes for the Content +Drive folder view, so the same orphaned owner reads the same in both listings. + +The field is never `null`, never absent and never `""`. + +## Where the field is produced + +`ExperimentView` (`com.dotcms.rest.api.v1.experiments`) wraps the Experiment for a response and adds +the field; the Experiment is `@JsonUnwrapped`, so the payload stays a flat object and the wire shape +is unchanged except for the addition. `ResponseEntitySingleExperimentView` and +`ResponseEntityExperimentView` do the wrapping, so none of the 14 endpoints changed. + +The `Experiment` schema in `openapi.yaml` is therefore untouched, and a new `ExperimentView` schema +carries the flattened fields plus `createdByUserName`. Both response wrappers now `$ref` the view. + +Because the model is a plain data object again, the field does **not** reach push-publish bundles or +starter exports, and those paths pay no user lookup — no mix-in or other mitigation is needed. + +## Deserialization contract (FR-023) + +`createdByUserName` is **read-only**: serialized on the way out, ignored on the way in. A payload +containing it must still deserialize into an `Experiment` without error. + +With the field on the view rather than the model, this is now free: the Experiment payload has no +serialize-only property, so it round-trips exactly as it did before this change. `ExperimentView` is +a response type and is never deserialized. The guard is `bareExperiment_stillRoundTripsAndKeepsItsContract`. + +## Verification + +```bash +# Regenerate and inspect the contract +JAVA_HOME="$HOME/.sdkman/candidates/java/25.0.2-ms" \ + ./mvnw compile -pl :dotcms-core --am -DskipTests -Ddocker.skip + +grep -A 8 '^ Experiment:' dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +git diff --stat dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml # must show the added property +``` + +The committed yaml must match what the build produces, or the CI contract check fails (FR-019). diff --git a/specs/37304-experiment-created-by-username/data-model.md b/specs/37304-experiment-created-by-username/data-model.md new file mode 100644 index 000000000000..ac341f2fec94 --- /dev/null +++ b/specs/37304-experiment-created-by-username/data-model.md @@ -0,0 +1,127 @@ +# Phase 1 Data Model: Creator Name on the Experiments API + +**Feature**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) + +No persistent data model changes. No table, column, index, migration or upgrade task. This document +describes the **in-memory and wire** shape only. + +--- + +## Entity: Experiment (`com.dotcms.experiments.model.AbstractExperiment`) + +### Existing members that this change must not disturb + +| Member | Kind | JSON | Invariant to preserve | +|---|---|---|---| +| `createdBy()` | abstract attribute, `String` | `createdBy` | Key, value (the creator's **user id**) and meaning unchanged — FR-005. Persisted in `experiment.created_by`. | +| `getOwner()` | `@Value.Derived`, `@JsonIgnore` | *(absent)* | Still returns `createdBy()`. Permission behaviour unchanged — FR-006. Stays out of the payload. | +| `lastModifiedBy()` | abstract attribute, `String` | `lastModifiedBy` | Untouched, no name companion — FR-008. | + +### New member + +| Property | Value | +|---|---| +| **Name** | `createdByUserName` | +| **Java** | `default String createdByUserName()` on `AbstractExperiment` | +| **Immutables kind** | **None** — deliberately not `@Value.Default/Derived/Lazy`, so it is not an attribute: no field, no builder method, no `equals`/`hashCode`/`toString` participation, no `build()` cost | +| **Jackson** | `@JsonProperty(value = "createdByUserName", access = JsonProperty.Access.READ_ONLY)` | +| **Swagger** | `@Schema(description = "...", example = "Admin User")` on the same method | +| **Type** | `String`, **always non-null and non-empty** (FR-003) — a real name, `System`, or `unknown` | +| **Persisted** | No — resolved per serialization (FR-016, FR-017) | +| **Settable** | No — read-only by design; `READ_ONLY` is what keeps an inbound payload parseable (FR-023) | +| **Derivation** | `ExperimentCreatorNameResolver.resolve(createdBy())` | + +### Evaluation timing (the property that makes the design work) + +| Path | Builds an Experiment? | Resolves the name? | +|---|---|---| +| `ExperimentTransformer` (every DB row: `find`, `list`) | Yes | **No** | +| `addTargetingConditions` → `withTargetingConditions` rebuild on `find` | Yes | **No** | +| `cacheRunningExperiments()` → running-experiments list cache (page render) | Yes | **No** — FR-015 | +| Push-publish dependency walk (`DependencyManager`) | Yes | **No** — FR-015 | +| Jackson serialization of a REST response | No | **Yes** — once per experiment per response | + +A `@Value.Derived` member would answer "Yes" in every row of that table; a `@Value.Lazy` member +would answer "Yes" once per instance and then memoize it — including in the shared, long-lived +running-experiments cache entries. See [research.md](./research.md) R1/R2. + +--- + +## Derivation rule: `ExperimentCreatorNameResolver` + +Input: the raw `createdBy` user id. Output: a non-empty display string. + +``` +resolve(createdById): + 1. createdById not set -> return "unknown" (never null/empty; FR-003) + 2. createdById is "system" -> return "System" (no lookup at all; FR-010a) + 3. user = userAPI.loadUserById(createdById) # UserCache-backed (research R3) + 4. fullName = user.getFullName() # first + middle + last + 5. fullName is set -> return fullName + 6. otherwise -> return "unknown" # blank-name user; FR-010 + on NoSuchUserException -> return "unknown" # deleted / orphaned; FR-009 + on DotDataException / any other -> return "unknown" # never fail the request; FR-011 +``` + +The two labels are `BrowserAPIImpl.ownerName`'s, which answers the same question for the Content +Drive folder view (FR-010b). That implementation reaches the user through `UserLocalManagerUtil` and +so bypasses `UserCache`; this one deliberately goes through `APILocator.getUserAPI()` instead. + +**Per-entry isolation** (FR-012): the rule is applied independently per experiment, so one bad id in +a list degrades exactly one entry. + +**Logging** (FR-013): `Logger.debug` for the expected not-found case — it is a data condition, not +an error, and debug is off by default, so a listing full of orphaned creators cannot flood the log. +`Logger.warn` for an unexpected infrastructure failure, which is rare by nature and worth surfacing. + +**Testability seam**: the resolver accepts a `UserAPI` so every branch above is unit-testable with a +mock; production code goes through `APILocator.getUserAPI()`. + +--- + +## Wire shape + +Before: + +```json +{ + "id": "0e8b8b1e-...", + "name": "Homepage CTA test", + "createdBy": "dotcms.org.1", + "lastModifiedBy": "dotcms.org.1" +} +``` + +After — one added key, nothing else moved: + +```json +{ + "id": "0e8b8b1e-...", + "name": "Homepage CTA test", + "createdBy": "dotcms.org.1", + "createdByUserName": "Admin User", + "lastModifiedBy": "dotcms.org.1" +} +``` + +Unresolvable creator (deleted user, or a user whose name parts are all blank): + +```json +{ + "createdBy": "deleted-user-id-4711", + "createdByUserName": "unknown" +} +``` + +Created by the system user: + +```json +{ + "createdBy": "system", + "createdByUserName": "System" +} +``` + +Property order in real responses is alphabetical — `DotObjectMapperProvider.createDefaultMapper()` +enables `SORT_PROPERTIES_ALPHABETICALLY` when `dotcms.rest.sort.json.properties` is true (the +default), which places `createdByUserName` immediately after `createdBy`. diff --git a/specs/37304-experiment-created-by-username/spec.md b/specs/37304-experiment-created-by-username/spec.md new file mode 100644 index 000000000000..cb6df07d6c2f --- /dev/null +++ b/specs/37304-experiment-created-by-username/spec.md @@ -0,0 +1,368 @@ +# Feature Specification: Creator Name on the Experiments API + +**Feature Branch**: `issue-37304-experiment-created-by-username` + +**Created**: 2026-09-10 + +**Status**: Draft + +**Type**: Task (additive, non-breaking API contract change) + +**Epic**: [#36763 — Experiments: A/B Testing v2](https://github.com/dotCMS/core/issues/36763) + +**Work item**: [dotCMS/core#37304 — Expose the experiment creator's username in the Experiments API](https://github.com/dotCMS/core/issues/37304) + +**Input**: User description: "Expose the experiment creator's username in the Experiments API" — taken from issue #37304. + +--- + +## Scope Note *(read this first)* + +Every Experiment already records who created it, but only as an opaque user ID. The payload says +`"createdBy": "dotcms.org.1"` and nothing else. That ID is not a name: it is the same value the +permission layer uses as the experiment's owner, and it means nothing to the person reading a +listing. + +The new Experiments portlet wants a **Created By** column. With today's contract the portlet has two +bad options: render the raw ID, or issue a second round of requests to translate every ID it sees +into a name. Both push work onto the client that the server can do once, cheaply, from data it has +already loaded. + +This work adds one field beside the existing one: `createdByUserName`, carrying the creator's full +name. It is **additive**. `createdBy` keeps its key, keeps its value and keeps its meaning, and the +owner/permission behaviour that reads it is untouched. Nothing that consumes the current payload has +to change. + +Because the field belongs to the Experiment itself and not to one endpoint's response shape, it +arrives on *every* response that carries an Experiment — the listing, the single fetch, and each of +the lifecycle and variant operations that return the updated experiment. + +Two things this work deliberately does **not** do. It does not touch `lastModifiedBy`, which has the +exact same ID-not-name problem and is left for a follow-up. And it does not store the name: the +value is resolved when the experiment is read, so an experiment created by someone who later changes +their name does not keep serving the old one. + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See who created each experiment in the listing (Priority: P1) + +A marketer opens the Experiments portlet and scans the list of experiments for the site. Each row +tells them who created that experiment by name — "Admin User", not "dotcms.org.1" — so they can find +their own work, or find the colleague to ask about someone else's. + +**Why this priority**: This is the whole point of the change and the reason the consumer issue +(#37307, listing Created By column) is blocked. The listing is where an ID is least useful, because +the reader is comparing many rows at once. + +**Independent Test**: Create experiments owned by two different known users, request the experiment +list, and confirm each entry reports the corresponding user's full name. + +**Acceptance Scenarios**: + +1. **Given** an experiment created by a user whose full name is "Admin User", **When** the experiment + list is requested, **Then** that experiment's entry reports `createdBy` unchanged as the user ID + **and** reports the creator's name as "Admin User". +2. **Given** a list containing experiments created by several different users, **When** the list is + requested, **Then** each entry reports the name of its own creator. +3. **Given** a list containing many experiments created by the *same* user, **When** the list is + requested, **Then** every entry reports that user's name and the response is served without a + perceptible slowdown compared with the same list before this change. + +--- + +### User Story 2 - The name travels with the experiment everywhere (Priority: P2) + +A client that has just created, started, ended, archived or otherwise modified an experiment gets the +updated experiment back and can render the creator's name from that response alone, without a +follow-up fetch and without special-casing which operation it just performed. + +**Why this priority**: Without it, a portlet that renders a row from a create/update response and a +row from the listing has to handle two different shapes for the same object. It is also what makes +the change cheap: one place to resolve the name, not fourteen. + +**Independent Test**: Exercise each endpoint whose response carries an Experiment and confirm the +creator's name is present in each one. + +**Acceptance Scenarios**: + +1. **Given** an existing experiment, **When** it is fetched by ID, **Then** the response carries the + creator's name. +2. **Given** an existing draft experiment, **When** it is started, ended, cancelled, archived, + updated, or has a variant added, removed, renamed or promoted, **Then** each of those responses + carries the creator's name for that experiment. +3. **Given** an experiment created through the create endpoint, **When** the create response is read, + **Then** it already carries the creator's name — no second request is needed. + +--- + +### User Story 3 - The column is never blank (Priority: P3) + +An administrator looks at a listing that includes experiments created by a user who has since been +deleted, and by the system itself. Every row still shows something identifying in the Created By +column, and no row is missing or empty. The listing loads normally. + +**Why this priority**: A rare data condition must not produce a broken-looking column or, worse, a +failed request that hides every other experiment in the list. + +**Independent Test**: Point an experiment's `createdBy` at a user ID that no longer resolves, request +both the list and the single fetch, and confirm the response succeeds and reports `unknown`. + +**Acceptance Scenarios**: + +1. **Given** an experiment whose creator ID does not resolve to any user, **When** the experiment is + requested, **Then** the request succeeds and the creator name field reports `unknown`. +2. **Given** an experiment created by the system user, **When** the experiment is requested, **Then** + the request succeeds and the creator name field reports `System`. +3. **Given** a list where one experiment has an unresolvable creator and the others do not, **When** + the list is requested, **Then** the request succeeds, the resolvable entries report real names and + only the affected entry reports `unknown`. + +--- + +### Edge Cases + +- **Creator no longer exists** (deleted user, orphaned reference): the field reports `unknown`. It is + never null, never absent and never an empty string. +- **Creator resolves but has no usable name** (first, middle and last name all blank): treated the + same as unresolvable — report `unknown`, so the column still says something honest. +- **Creator is the system user**: reported as `System`, short-circuited before any lookup. The + request never fails because of it. +- **User lookup fails for an infrastructure reason** (database error, cache error): the experiment is + still returned successfully with the fallback value. A failure to decorate must never turn a + successful experiment read into a failed request. +- **Same creator repeated across a long list**: resolving the name must not cost one uncached lookup + per row. +- **Creator renames themselves**: subsequent reads report the new name. The value is resolved from + current user data at read time, not captured when the experiment was created. +- **An experiment payload is read back in** (a client echoing a response, a future import path): the + extra field must not make that payload unreadable. +- **Experiment with no variants / archived / ended**: status has no bearing on the field; it is + present in every state. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +**The field** + +- **FR-001**: Every API response that carries an Experiment MUST include a creator-name field, + `createdByUserName`, alongside the existing `createdBy`. +- **FR-002**: `createdByUserName` MUST carry the full name of the user identified by that + experiment's `createdBy` value. +- **FR-003**: `createdByUserName` MUST always be present and non-empty. It is never null, never + omitted, and never an empty string. +- **FR-004**: The field MUST be a single display-ready string. The API is not required to expose + first name and last name separately. + +**Nothing existing changes** + +- **FR-005**: `createdBy` MUST keep its current JSON key and its current value (the user ID). No + consumer of the present contract may break. +- **FR-006**: Owner and permission behaviour MUST be unchanged: the experiment's owner continues to + resolve from `createdBy`, never from the new field. +- **FR-007**: No other existing Experiment field may be removed, renamed, retyped or reordered by + this work. The change is strictly additive. +- **FR-008**: `lastModifiedBy` MUST be left exactly as it is today — same key, same value, no name + companion. It is out of scope (see Out of Scope). + +**Fallback and failure** + +- **FR-009**: When the creator ID does not resolve to a user, `createdByUserName` MUST report + `unknown`. +- **FR-010**: When the creator resolves to a user whose name is blank, `createdByUserName` MUST + report `unknown` under the same rule as FR-009. +- **FR-010a**: When the creator is the system user, `createdByUserName` MUST report `System`, + without a user lookup. +- **FR-010b**: These labels MUST match the ones the Content Drive folder view already publishes for + the same question (`BrowserAPIImpl.ownerName`): `System` for the system user, `unknown` for an + owner that cannot be resolved. Two listings in the same product must not label the same orphaned + owner differently. +- **FR-011**: A failure to resolve the creator MUST NOT fail the experiment request. The endpoint + still returns its normal success response with the experiment payload and the fallback value. +- **FR-012**: A failure to resolve one experiment's creator inside a list MUST NOT affect the other + entries in that list. +- **FR-013**: Resolution failures MUST be observable to an operator (logged), without the log line + being emitted once per row of a large listing. + +**Cost and freshness** + +- **FR-014**: Serving a list of N experiments MUST NOT perform N uncached user lookups. Resolution is + per distinct creator, and repeated creators cost no additional database work. +- **FR-015**: The change MUST NOT add user lookups to code paths that do not serialize an Experiment + — in particular the running-experiment selection performed during page rendering, and the + push-publish dependency walk. Reading an experiment for those purposes must cost what it costs + today. +- **FR-016**: `createdByUserName` MUST reflect the creator's current name. The name MUST NOT be + captured at experiment-creation time and stored beside the experiment. +- **FR-017**: This work MUST NOT introduce a database schema change. No new column, no migration, no + upgrade task. + +**Contract, docs and tests** + +- **FR-018**: `createdByUserName` MUST be documented in the API schema for the Experiment, with a + description that states it is the creator's display name and that it falls back to the creator ID. + The documented type MUST match what is actually returned. +- **FR-019**: The committed `openapi.yaml` MUST be regenerated from the annotations and committed + together with the code change, so the CI contract check passes. +- **FR-020**: Integration tests MUST cover: the happy path from the list endpoint, the happy path + from the single-fetch endpoint, and the unresolvable-creator fallback returning success plus + `unknown`. +- **FR-021**: Every new integration test class MUST be registered in a `MainSuite*` / `Junit5Suite*` + `@SuiteClasses` list, otherwise it never runs in CI. +- **FR-022**: Existing experiment tests MUST continue to pass unchanged, in particular any asserting + on the shape or value of `createdBy`. +- **FR-023**: An Experiment payload that contains `createdByUserName` MUST still be readable wherever + Experiment JSON is parsed back into an Experiment. The added field must not make a round-tripped + payload fail to parse. +- **FR-023a**: `createdByUserName` MUST NOT appear on the `Experiment` model itself, and therefore + MUST NOT reach push-publish bundles or starter exports. It is published by the REST view only, so + the endpoint response carries it and nothing else does. + +### Endpoints in scope + +Every endpoint below returns a payload carrying one or more Experiments and is therefore covered by +FR-001. This list is taken from the resource as it exists today, not from the issue body (see +Assumptions, A7): + +| # | Endpoint | +|---|---| +| 1 | `POST /v1/experiments` (create) | +| 2 | `PATCH /v1/experiments/{experimentId}` (partial update) | +| 3 | `PUT /v1/experiments/{experimentId}/_archive` | +| 4 | `GET /v1/experiments/{id}` | +| 5 | `GET /v1/experiments` (list) | +| 6 | `DELETE /v1/experiments/{experimentId}/goals/primary` | +| 7 | `POST /v1/experiments/{experimentId}/_start` | +| 8 | `POST /v1/experiments/{experimentId}/_end` | +| 9 | `POST /v1/experiments/scheduled/{experimentId}/_cancel` | +| 10 | `POST /v1/experiments/{experimentId}/variants` | +| 11 | `DELETE /v1/experiments/{experimentId}/variants/{name}` | +| 12 | `PUT /v1/experiments/{experimentId}/variants/{name}` | +| 13 | `PUT /v1/experiments/{experimentId}/variants/{name}/_promote` | +| 14 | `DELETE /v1/experiments/{experimentId}/targetingConditions/{id}` | + +Endpoints under `/v1/experiments` that do **not** carry an Experiment, and are therefore untouched: +`DELETE /v1/experiments/{experimentId}` (returns a confirmation message), `POST /v1/experiments/isUserIncluded`, +`GET /v1/experiments/{id}/results`, `GET /v1/experiments/health`. + +### Key Entities + +- **Experiment**: the object being serialized. Already carries `createdBy` (the creator's user ID, + also used as the permission owner) and `lastModifiedBy`. Gains one read-time field, + `createdByUserName`, that is derived from `createdBy` and not persisted. +- **User**: the creator behind `createdBy`. Supplies the display name. May not exist any more, and + may have no usable name; both cases resolve to the fallback rule. + +--- + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A reader of an experiment listing can identify the creator of every row by name, + without the client making any request beyond the one that returned the list. +- **SC-002**: 100% of the 14 endpoints listed under "Endpoints in scope" return the creator name + field; 0% of the endpoints outside that list change shape. +- **SC-003**: 0 existing Experiment fields change key, value, type or meaning — verified by the + existing experiment test suite passing without modification. +- **SC-004**: For a listing of 50 experiments created by a single user, the response time is + indistinguishable from the same listing before this change (within normal run-to-run variation), + and the number of user records read from the database is at most 1. +- **SC-005**: 100% of requests for an experiment whose creator cannot be resolved still succeed and + report a non-empty creator name value. +- **SC-006**: The published API documentation describes the new field, and the repository's generated + contract file matches what the build produces — the CI contract check passes on the first run. +- **SC-007**: The consumer work (#37307, listing Created By column) can be built against the + documented contract with no further backend change. + +--- + +## Assumptions + +- **A1 — User lookups are already cache-backed.** Loading a user by ID consults the user cache before + hitting the database (`UserFactoryLiferayImpl.loadUserById` reads `UserCache` first and populates + it on a miss). A listing whose experiments share a creator therefore costs one database read for + the first row and none afterwards, which is what FR-014 relies on. FR-014 still stands as a + requirement rather than a freebie, because the cache is not infinite and a listing spanning many + distinct creators must not degenerate into one lookup per row. + +- **A2 — The mechanism for a derived JSON field is a plan-phase decision, and it is constrained.** + The Experiment model is an Immutables value type serialized by Jackson. The two obvious immutables + mechanisms behave differently and neither is free: + - an eagerly-derived attribute is computed at build time, so it would fire on *every* Experiment + construction, including the ones that never reach a response (the database transformer, the + push-publish dependency walk, running-experiment selection during page rendering) — which + FR-015 forbids; + - a lazily-computed attribute is memoized inside the instance, and the running-experiments list + is cached whole, so a memoized name on those long-lived shared entries would outlive a rename — + which FR-016 forbids. (The per-experiment cache in `ExperimentsFactoryImpl.find` is commented + out with a TODO, so `find` and `list` build fresh instances on every call; only that list cache + is live.) + + Neither mechanism is settable from JSON, which is where FR-023 comes from. The spec therefore + states the constraints (FR-014, FR-015, FR-016, FR-023) and leaves the mechanism to `/speckit-plan`, + which must verify its choice against all four before committing to it. + +- **A3 — Documenting the field needs no new endpoint annotations.** `ExperimentsResource` today + carries only a `@Tag`; it has no `@Operation`/`@ApiResponse` annotations at all, and the Experiment + schema in `openapi.yaml` is derived from the model. A schema annotation placed on the model's + accessor does reach the generated contract — verified against `AbstractTimestampsView`, whose + per-accessor descriptions and examples appear verbatim under the `TimestampsView` schema. So + FR-018 is satisfied by annotating the model, and adding a full Swagger annotation pass to + `ExperimentsResource` is **not** part of this work. + +- **A4 — "Full name" means the platform's existing notion of a full name**: first, middle and last + name joined as `User.getFullName()` already does, rather than a new formatting rule invented here. + That method joins the parts with spaces and never trims, so it returns a single space when every part is blank, and leaves padding like "Admin " when only one part is set, which is exactly the case FR-010 + covers. + +- **A5 — The name is derived in the REST layer, not on the model.** An earlier revision put the + accessor on `AbstractExperiment` itself. That made every serialization of an Experiment resolve a + user, and serialization is not only REST: `ExperimentBundler` and `ExperimentHandler` are live — + they simply live under `dotCMS/src/enterprise/java` rather than `src/main/java`, which is why an + earlier draft of this spec wrongly called them absent. The bundler serializes the whole Experiment + and the handler reads it back through a mapper that rejects unknown properties, while + `BundlePublisher` runs every handler in one transaction — so a receiver on a build without the + field would have rolled back the **entire** bundle. Starter export (`ExportStarterUtil`) + serializes Experiments the same way. Review (#37510) asked for the derivation to move to the REST + layer, and it did: `ExperimentView` carries it, the model is a plain data object again, and the + problem is removed at the source rather than mitigated. + +- **A6 — The consumer needs one display string.** #37307 renders a single Created By column, so a + single pre-joined name is sufficient; separate first/last fields are not required. + +- **A7 — The issue's endpoint list is close but not exact**, and the table above supersedes it. The + issue lists `delete` among the endpoints that carry an Experiment; `DELETE /v1/experiments/{id}` + actually returns a confirmation message and no experiment. The issue also omits two endpoints that + *do* return an Experiment: `DELETE /v1/experiments/{experimentId}/goals/primary` and + `DELETE /v1/experiments/{experimentId}/targetingConditions/{id}`. Both differences are corrections + to the issue text only — they do not change the approach, because the field is added once at the + Experiment level and every experiment-carrying response inherits it. + +- **A8 — Backend only.** No frontend work belongs to this issue; the portlet column is #37307. + +--- + +## Out of Scope + +- **`lastModifiedBy`.** It has the same ID-not-name problem and is deliberately excluded. If the + portlet later surfaces a "last modified by" column, it can be added with the same mechanism as a + follow-up issue. +- **The portlet's Created By column itself** (#37307) — this work only supplies the data. +- **Storing the creator's name.** No new column, no migration, no denormalized copy (FR-016, FR-017). +- **Paging, sorting, counting or permission filtering on the experiment list.** Known gaps in the + list endpoint, tracked separately; this change must not attempt them and must not make them harder. +- **Names for any other user reference** in the experiment payload or elsewhere in the API. +- **A Swagger annotation pass over `ExperimentsResource`.** Only the new field is documented (A3). + +--- + +## Dependencies + +- **Consumer**: [#37307](https://github.com/dotCMS/core/issues/37307) — the Experiments portlet + listing Created By column — is blocked on this field and must not merge before it. +- **Epic**: [#36763](https://github.com/dotCMS/core/issues/36763) — Experiments: A/B Testing v2.