diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.spec.ts index adb34f5393fb..fb68d9732f17 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.spec.ts @@ -1,14 +1,9 @@ -import { - Spectator, - SpyObject, - byTestId, - createComponentFactory, - mockProvider -} from '@ngneat/spectator/jest'; -import { of, throwError } from 'rxjs'; +import { Spectator, byTestId, createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { - DotCrudService, DotHttpErrorManagerService, DotMessageDisplayService, DotMessageService @@ -38,13 +33,26 @@ const MOCK_CONTENT_TYPE = { metadata: {} } as DotCMSContentType; +// Content type with fields, workflows, and systemActionMappings to verify none of these +// bleed into the metadata-only PATCH payload. +const MOCK_CONTENT_TYPE_WITH_FIELDS_AND_WORKFLOWS = { + ...MOCK_CONTENT_TYPE, + fields: [{ id: 'field-1', variable: 'title', name: 'Title' }], + workflows: [{ id: 'workflow-scheme-id', name: 'Default Workflow' }], + systemActionMappings: { NEW: { id: 'wf-action-id' } } +} as unknown as DotCMSContentType; + +const METADATA_URL = `v1/contenttype/id/${MOCK_CONTENT_TYPE.id}/metadata`; + describe('DotStyleEditorBuilderComponent', () => { let spectator: Spectator; + let httpController: HttpTestingController; const createComponent = createComponentFactory({ component: DotStyleEditorBuilderComponent, providers: [ - mockProvider(DotCrudService, { putData: jest.fn().mockReturnValue(of({})) }), + provideHttpClient(), + provideHttpClientTesting(), mockProvider(DotHttpErrorManagerService, { handle: jest.fn() }), mockProvider(DotMessageDisplayService, { push: jest.fn() }), { @@ -62,6 +70,7 @@ describe('DotStyleEditorBuilderComponent', () => { function setup(contentType?: DotCMSContentType): void { spectator = createComponent(); + httpController = spectator.inject(HttpTestingController); if (contentType) { spectator.setInput('contentType', contentType); } @@ -93,6 +102,10 @@ describe('DotStyleEditorBuilderComponent', () => { spectator.detectChanges(); } + afterEach(() => { + httpController.verify(); + }); + describe('Sections', () => { it('should add a section when "Add New Section" is clicked', () => { setup(); @@ -249,39 +262,58 @@ describe('DotStyleEditorBuilderComponent', () => { spectator.detectChanges(); expect(spectator.component.$saveAttempted()).toBe(true); - expect(spectator.inject(DotCrudService).putData).not.toHaveBeenCalled(); + httpController.expectNone(METADATA_URL); }); - it('should call the CRUD API when the form is valid', () => { + it('should call the metadata PATCH endpoint when the form is valid', () => { setup(MOCK_CONTENT_TYPE); - // No sections → empty form is valid (nothing to validate) + spectator.query(byTestId('save-btn'))?.querySelector('button')?.click(); spectator.detectChanges(); - expect(spectator.inject(DotCrudService).putData).toHaveBeenCalledWith( - `v1/contenttype/id/${MOCK_CONTENT_TYPE.id}`, - expect.anything() - ); + const req = httpController.expectOne(METADATA_URL); + expect(req.request.method).toBe('PATCH'); + req.flush({ entity: {} }); }); - it('should handle API errors by calling the error manager', () => { + it('should send null for the schema key when there are no sections', () => { setup(MOCK_CONTENT_TYPE); - const crudService: SpyObject = spectator.inject(DotCrudService); - crudService.putData.mockReturnValue(throwError(() => new Error('Server error'))); + spectator.query(byTestId('save-btn'))?.querySelector('button')?.click(); + spectator.detectChanges(); + + const req = httpController.expectOne(METADATA_URL); + expect(req.request.body).toEqual({ DOT_STYLE_EDITOR_SCHEMA: null }); + req.flush({ entity: {} }); + }); + + it('should send only the schema key in the payload — no fields, workflows or other CT properties', () => { + setup(MOCK_CONTENT_TYPE_WITH_FIELDS_AND_WORKFLOWS); spectator.query(byTestId('save-btn'))?.querySelector('button')?.click(); spectator.detectChanges(); + const req = httpController.expectOne(METADATA_URL); + expect(Object.keys(req.request.body)).toEqual(['DOT_STYLE_EDITOR_SCHEMA']); + req.flush({ entity: {} }); + }); + + it('should handle API errors by calling the error manager', () => { + setup(MOCK_CONTENT_TYPE); + + spectator.query(byTestId('save-btn'))?.querySelector('button')?.click(); + spectator.detectChanges(); + + httpController + .expectOne(METADATA_URL) + .flush('Server error', { status: 500, statusText: 'Internal Server Error' }); + spectator.detectChanges(); + expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalled(); }); }); describe('Duplicate identifier validation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('should detect a duplicate when two fields in the same section share an identifier', () => { setup(MOCK_CONTENT_TYPE); @@ -321,7 +353,7 @@ describe('DotStyleEditorBuilderComponent', () => { spectator.detectChanges(); expect(spectator.component.$saveAttempted()).toBe(true); - expect(spectator.inject(DotCrudService).putData).not.toHaveBeenCalled(); + httpController.expectNone(METADATA_URL); }); it('should call the API after the user renames one of the duplicate identifiers to make it unique', () => { @@ -339,7 +371,9 @@ describe('DotStyleEditorBuilderComponent', () => { spectator.query(byTestId('save-btn'))?.querySelector('button')?.click(); spectator.detectChanges(); - expect(spectator.inject(DotCrudService).putData).toHaveBeenCalled(); + const req = httpController.expectOne(METADATA_URL); + expect(req.request.method).toBe('PATCH'); + req.flush({ entity: {} }); }); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts index 6fd5ee210be7..d603d8fab22b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts @@ -1,5 +1,6 @@ import { patchState, signalState } from '@ngrx/signals'; +import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, @@ -20,12 +21,16 @@ import { TooltipModule } from 'primeng/tooltip'; import { take } from 'rxjs/operators'; import { - DotCrudService, DotHttpErrorManagerService, DotMessageDisplayService, DotMessageService } from '@dotcms/data-access'; -import { DotCMSContentType, DotMessageSeverity, DotMessageType } from '@dotcms/dotcms-models'; +import { + DotCMSContentType, + DotCMSResponse, + DotMessageSeverity, + DotMessageType +} from '@dotcms/dotcms-models'; import { StyleEditorFieldSchema, StyleEditorFormSchema } from '@dotcms/types/internal'; import { DotMessagePipe } from '@dotcms/ui'; import { StyleEditorField, defineStyleEditorSchema, styleEditorField } from '@dotcms/uve/internal'; @@ -83,7 +88,7 @@ interface DotStyleEditorBuilderState { changeDetection: ChangeDetectionStrategy.OnPush }) export class DotStyleEditorBuilderComponent { - readonly #crudService = inject(DotCrudService); + readonly #http = inject(HttpClient); readonly #dotHttpErrorManagerService = inject(DotHttpErrorManagerService); readonly #dotMessageDisplayService = inject(DotMessageDisplayService); readonly #dotMessageService = inject(DotMessageService); @@ -187,15 +192,11 @@ export class DotStyleEditorBuilderComponent { const contentType = this.$contentType(); if (!contentType) return; - const existingMetadata = { ...(contentType.metadata ?? {}) }; - - let updatedMetadata: typeof existingMetadata; + let metadataPatch: Record; if (this.$sections().length === 0) { - // Empty form — remove the key so metadata stays clean (no empty schema noise) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [STYLE_EDITOR_SCHEMA_KEY]: _removed, ...rest } = existingMetadata; - updatedMetadata = rest; + // Null tells the PATCH endpoint to remove the key entirely + metadataPatch = { [STYLE_EDITOR_SCHEMA_KEY]: null }; } else { const schema = defineStyleEditorSchema({ contentType: contentType.variable, @@ -204,25 +205,12 @@ export class DotStyleEditorBuilderComponent { fields: section.fields.map((field) => this.#toStyleEditorField(field)) })) }); - updatedMetadata = { - ...existingMetadata, - [STYLE_EDITOR_SCHEMA_KEY]: JSON.stringify(schema) - }; + metadataPatch = { [STYLE_EDITOR_SCHEMA_KEY]: JSON.stringify(schema) }; } - // `systemActionMappings` contains full workflow-action objects that the API - // misinterprets as action IDs when round-tripped in a PUT body. Strip it out. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { systemActionMappings: _wf, ...contentTypeData } = contentType; - - const payload: DotCMSContentType = { - ...contentTypeData, - metadata: updatedMetadata - }; - patchState(this.#state, { saving: true }); - this.#crudService - .putData(`v1/contenttype/id/${contentType.id}`, payload) + this.#http + .patch(`v1/contenttype/id/${contentType.id}/metadata`, metadataPatch) .pipe(take(1), takeUntilDestroyed(this.#destroyRef)) .subscribe({ next: () => { @@ -253,8 +241,11 @@ export class DotStyleEditorBuilderComponent { */ #loadFromMetadata(contentType: DotCMSContentType): void { const raw = contentType.metadata?.[STYLE_EDITOR_SCHEMA_KEY]; - if (!raw || typeof raw !== 'string') { - console.warn('[StyleEditorBuilder] Invalid schema in metadata'); + if (!raw) { + return; + } + if (typeof raw !== 'string') { + console.warn('[StyleEditorBuilder] DOT_STYLE_EDITOR_SCHEMA is not a string; ignoring'); return; } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeHelper.java index 10148f42078f..fc81cecafb16 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeHelper.java @@ -3,7 +3,13 @@ import com.dotcms.api.web.HttpServletRequestThreadLocal; import com.dotcms.api.web.HttpServletResponseThreadLocal; import com.dotcms.business.WrapInTransaction; +import com.dotcms.concurrent.DotConcurrentFactory; +import com.dotcms.concurrent.lock.IdentifierStripedLock; +import com.dotcms.contenttype.business.ContentTypeAPI; import com.dotcms.contenttype.exception.NotFoundInDbException; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotcms.rest.exception.BadRequestException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.dotcms.contenttype.model.field.CustomField; import com.dotcms.contenttype.model.field.Field; import com.dotcms.contenttype.model.field.ImmutableCustomField; @@ -52,6 +58,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -987,4 +994,103 @@ public List getEnsuredContentTypes(final String ensuredContentTypes) { .collect(Collectors.toList()); } + /** + * Atomically merges the given metadata patch into the specified Content Type's metadata and + * persists the result. + *

+ * The operation is serialized per Content Type using a JVM-local striped lock keyed on the + * content type ID. This prevents lost updates when two concurrent PATCH requests target the + * same Content Type: the second request waits until the first has committed, then re-reads the + * freshest metadata before applying its own changes. + *

+ * Note: the lock is JVM-local. Concurrent writes across cluster nodes are still subject to + * last-write-wins; true multi-node safety requires either optimistic locking on {@code modDate} + * or an atomic DB-level JSON merge, and should be addressed in a follow-up. + * + * @param idOrVar ID or velocity variable name of the Content Type to patch + * @param metadataPatch keys to merge; a {@code null} value removes the key from the map + * @param contentTypeAPI the API instance to use for reading and saving the Content Type + * @return the saved Content Type with the merged metadata + * @throws DotDataException if a database error occurs + * @throws DotSecurityException if the user does not have permission to edit the Content Type + * @throws BadRequestException if {@code DOT_STYLE_EDITOR_SCHEMA} is present but cannot be + * serialized to a JSON string + */ + public ContentType mergeAndSaveMetadata( + final String idOrVar, + final Map metadataPatch, + final ContentTypeAPI contentTypeAPI) throws DotDataException, DotSecurityException { + + // Defensive copy — protects against callers passing immutable maps (e.g. Map.of(...)), + // which would cause UnsupportedOperationException inside normalizeStyleEditorSchemaToString + final Map patch = new HashMap<>(metadataPatch); + + // Validate/normalize before acquiring the lock — fail fast on bad input + normalizeStyleEditorSchemaToString(patch); + + final IdentifierStripedLock lockManager = + DotConcurrentFactory.getInstance().getIdentifierStripedLock(); + try { + // Initial find to obtain the stable ID used as the lock key + final ContentType initial = contentTypeAPI.find(idOrVar); + + return lockManager.tryLock("ct-metadata-" + initial.id(), () -> { + // Re-read inside the lock to pick up any writes committed before we acquired it + final ContentType current = contentTypeAPI.find(initial.id()); + final Map merged = new HashMap<>( + current.metadata() != null ? current.metadata() : Map.of()); + patch.forEach((k, v) -> { + if (v == null) { + merged.remove(k); + } else { + merged.put(k, v); + } + }); + return contentTypeAPI.save( + ContentTypeBuilder.builder(current).metadata(merged).build()); + }); + } catch (final DotDataException | DotSecurityException e) { + throw e; + } catch (final Throwable t) { + throw new DotDataException( + "Error patching metadata for Content Type '" + idOrVar + "': " + t.getMessage(), t); + } + } + + /** + * Normalizes the {@code DOT_STYLE_EDITOR_SCHEMA} entry in the given metadata patch map so that + * its value is always stored as a JSON string rather than a raw JSON object. + *

+ * When a client sends the schema as a JSON object (e.g. a deserialized {@link java.util.Map}), + * Jackson binds it as a {@code LinkedHashMap}. Page-rendering code downstream casts the stored + * value directly to {@code String}, so tolerating a non-String value would cause a + * {@link ClassCastException} at render time. This method serializes any non-String, non-null + * value back to a compact JSON string and writes it in-place into {@code metadataPatch}. + *

+ * Keys other than {@code DOT_STYLE_EDITOR_SCHEMA}, and a {@code null} value for that key + * (which signals "remove the key"), are left untouched. + * + * @param metadataPatch the mutable metadata patch map; modified in place when normalization + * is needed + * @throws BadRequestException if the value cannot be serialized to a JSON string + */ + private static void normalizeStyleEditorSchemaToString(final Map metadataPatch) { + final Object rawSchema = metadataPatch.get("DOT_STYLE_EDITOR_SCHEMA"); + if (rawSchema == null || rawSchema instanceof String) { + return; + } + + final ObjectMapper mapper = DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + final String schemaStr; + try { + schemaStr = mapper.writeValueAsString(rawSchema); + } catch (final Exception e) { + Logger.warn(ContentTypeHelper.class, + "Could not serialize DOT_STYLE_EDITOR_SCHEMA to JSON string: " + e.getMessage()); + throw new BadRequestException( + "DOT_STYLE_EDITOR_SCHEMA must be a serializable JSON value"); + } + metadataPatch.put("DOT_STYLE_EDITOR_SCHEMA", schemaStr); + } + } // E:O:F:ContentTypeHelper. \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java index c94f9b5c2297..42ae8ffcbf4a 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java @@ -16,9 +16,11 @@ import com.dotcms.contenttype.model.field.FieldVariable; import com.dotcms.contenttype.model.type.BaseContentType; import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.contenttype.model.type.ContentTypeBuilder; import com.dotcms.contenttype.transform.contenttype.ContentTypeInternationalization; import com.dotcms.exception.ExceptionUtil; import com.dotcms.rendering.velocity.services.PageRenderUtil; +import com.dotcms.rest.PATCH; import com.google.common.annotations.VisibleForTesting; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.ResponseEntityPaginatedDataView; @@ -29,12 +31,12 @@ import com.dotcms.rest.annotation.NoCache; import com.dotcms.rest.annotation.PermissionsUtil; import com.dotcms.rest.annotation.SwaggerCompliant; +import com.dotcms.rest.ErrorEntity; import com.dotcms.rest.exception.BadRequestException; import com.dotcms.rest.exception.ForbiddenException; import com.dotcms.rest.exception.mapper.ExceptionMapperUtil; import com.dotcms.util.ConversionUtils; import com.dotcms.util.PaginationUtil; -import com.dotcms.util.PaginationUtilParams; import com.dotcms.util.PaginationUtilParams.Builder; import com.dotcms.util.diff.DiffItem; import com.dotcms.util.diff.DiffResult; @@ -2018,6 +2020,106 @@ public final ResponseEntityPaginatedDataView getPagesContentTypes(@Context final return util.getPageView(builder.build()); } + @PATCH + @Path("/id/{idOrVar}/metadata") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Operation( + operationId = "updateContentTypeMetadata", + summary = "Updates the metadata of a Content Type", + description = "Merges the provided key/value pairs into the Content Type's `metadata` " + + "map without touching fields, workflows, or any other structural property. " + + "Keys present in the body are added or overwritten; keys absent from the body " + + "are left unchanged. To remove a key, set its value explicitly to `null`.\n\n" + + "Known metadata keys:\n" + + "- `CONTENT_EDITOR2_ENABLED` *(boolean)* — enables the new content editor\n" + + "- `DOT_STYLE_EDITOR_SCHEMA` *(JSON string)* — Style Editor schema definition", + tags = {"Content Type"}, + responses = { + @ApiResponse(responseCode = "200", description = "Metadata updated successfully", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ResponseEntityContentTypeDetailView.class))), + @ApiResponse(responseCode = "400", description = "Bad request — invalid JSON body"), + @ApiResponse(responseCode = "401", description = "User not authenticated"), + @ApiResponse(responseCode = "403", description = "User does not have permission to edit this Content Type"), + @ApiResponse(responseCode = "404", description = "Content Type not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + } + ) + public final Response updateContentTypeMetadata( + @Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("idOrVar") @Parameter( + required = true, + description = "The ID or Velocity variable name of the Content Type to update.\n\n" + + "Example value: `htmlpageasset` (Default page content type)", + schema = @Schema(type = "string") + ) final String idOrVar, + @RequestBody( + description = "A flat JSON object whose keys are merged into the Content Type's " + + "existing metadata. Set a key's value to `null` to remove it. " + + "An absent or empty body is treated as a no-op — the current metadata " + + "is returned unchanged with HTTP 200.", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = "object", description = "Metadata key/value pairs to merge"), + examples = @ExampleObject( + value = "{\n" + + " \"DOT_STYLE_EDITOR_SCHEMA\": \"{\\\"contentType\\\":\\\"htmlpageasset\\\"," + + "\\\"sections\\\":[{\\\"title\\\":\\\"New Section\\\",\\\"fields\\\":" + + "[{\\\"type\\\":\\\"dropdown\\\",\\\"label\\\":\\\"Color\\\"," + + "\\\"id\\\":\\\"color\\\",\\\"config\\\":{\\\"options\\\":" + + "[{\\\"label\\\":\\\"Red\\\",\\\"value\\\":\\\"red\\\"}]}}]}]}\"\n" + + "}" + ) + ) + ) final Map metadataPatch) { + + final User user = new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, response) + .rejectWhenNoUser(true) + .init() + .getUser(); + + final ContentTypeAPI contentTypeAPI = APILocator.getContentTypeAPI(user, true); + try { + if (!UtilMethods.isSet(idOrVar)) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + if (metadataPatch == null || metadataPatch.isEmpty()) { + Logger.warn(this, "No metadata patch found for " + idOrVar); + final ContentType existing = contentTypeAPI.find(idOrVar); + return Response.ok(new ResponseEntityContentTypeDetailView( + new HashMap<>(contentTypeHelper.contentTypeToMap(existing, user)))).build(); + } + + final ContentType saved = contentTypeHelper.mergeAndSaveMetadata(idOrVar, metadataPatch, contentTypeAPI); + return Response.ok(new ResponseEntityContentTypeDetailView( + new HashMap<>(contentTypeHelper.contentTypeToMap(saved, user)))).build(); + } catch (final NotFoundInDbException e) { + Logger.warn(this, String.format("Content Type '%s' was not found", idOrVar)); + return Response.status(Response.Status.NOT_FOUND) + .entity(new ResponseEntityContentTypeDetailView( + List.of(new ErrorEntity("CONTENT_TYPE_NOT_FOUND", "Content type not found", idOrVar)) + )).build(); + } catch (final BadRequestException e) { + Logger.warn(this, String.format("Bad metadata patch for Content Type '%s': %s", idOrVar, e.getMessage())); + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityContentTypeDetailView( + List.of(new ErrorEntity("INVALID_METADATA", e.getMessage(), "DOT_STYLE_EDITOR_SCHEMA")) + )).build(); + } catch (final DotSecurityException e) { + throw new ForbiddenException(e); + } catch (final Exception e) { + Logger.error(this, String.format("Error updating metadata for Content Type '%s'", idOrVar), e); + return ExceptionMapperUtil.createResponse(e, Response.Status.INTERNAL_SERVER_ERROR); + } + } + private static long getLanguageId(final String language) { final long userLanguageId = LanguageUtil.getLanguageId(language); diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ResponseEntityContentTypeDetailView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ResponseEntityContentTypeDetailView.java index 7cbc42843ba4..4b951086a071 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ResponseEntityContentTypeDetailView.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ResponseEntityContentTypeDetailView.java @@ -1,6 +1,8 @@ package com.dotcms.rest.api.v1.contenttype; +import com.dotcms.rest.ErrorEntity; import com.dotcms.rest.ResponseEntityView; +import java.util.List; import java.util.Map; /** @@ -17,4 +19,14 @@ public ResponseEntityContentTypeDetailView(final Map entity) { public ResponseEntityContentTypeDetailView(final Map entity, final String[] permissions) { super(entity, permissions); } + + /** + * Error-only constructor — used when the operation failed before a Content Type could be + * returned. The entity is {@code null}; all error detail lives in the {@code errors} list. + * + * @param errors list of errors describing what went wrong + */ + public ResponseEntityContentTypeDetailView(final List errors) { + super(errors, (Map) null); + } } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java index f8b25060bd8e..230bd5e4b8e1 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java @@ -625,7 +625,7 @@ public final Response findAllSchemesByContentTypeList( .map(String::trim) .filter(UtilMethods::isSet) .distinct() - .collect(Collectors.toList()); + .toList(); if (ids.isEmpty()) { throw new IllegalArgumentException("contentTypeIds must not be empty"); @@ -665,7 +665,7 @@ public final Response findAllSchemesByContentTypeList( if (!errors.isEmpty()) { Logger.warn(this, "Completed with " + errors.size() + " error(s): " + - errors.stream().map(ErrorEntity::getFieldName).collect(Collectors.toList())); + errors.stream().map(ErrorEntity::getFieldName).toList()); } return Response.ok(new ResponseEntityContentTypeWorkflowSchemesView(errors, result)).build(); diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index c29c8129bf8c..95984341ad88 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -8148,6 +8148,60 @@ paths: summary: Updates a content type tags: - Content Type + /v1/contenttype/id/{idOrVar}/metadata: + patch: + description: |- + Merges the provided key/value pairs into the Content Type's `metadata` map without touching fields, workflows, or any other structural property. Keys present in the body are added or overwritten; keys absent from the body are left unchanged. To remove a key, set its value explicitly to `null`. + + Known metadata keys: + - `CONTENT_EDITOR2_ENABLED` *(boolean)* — enables the new content editor + - `DOT_STYLE_EDITOR_SCHEMA` *(JSON string)* — Style Editor schema definition + operationId: updateContentTypeMetadata + parameters: + - description: |- + The ID or Velocity variable name of the Content Type to update. + + Example value: `htmlpageasset` (Default page content type) + in: path + name: idOrVar + required: true + schema: + type: string + requestBody: + content: + application/json: + example: + DOT_STYLE_EDITOR_SCHEMA: "{\"contentType\":\"htmlpageasset\",\"sections\"\ + :[{\"title\":\"New Section\",\"fields\":[{\"type\":\"dropdown\",\"\ + label\":\"Color\",\"id\":\"color\",\"config\":{\"options\":[{\"label\"\ + :\"Red\",\"value\":\"red\"}]}}]}]}" + schema: + type: object + description: Metadata key/value pairs to merge + description: A flat JSON object whose keys are merged into the Content Type's + existing metadata. Set a key's value to `null` to remove it. An absent or + empty body is treated as a no-op — the current metadata is returned unchanged + with HTTP 200. + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityContentTypeDetailView" + description: Metadata updated successfully + "400": + description: Bad request — invalid JSON body + "401": + description: User not authenticated + "403": + description: User does not have permission to edit this Content Type + "404": + description: Content Type not found + "500": + description: Internal server error + summary: Updates the metadata of a Content Type + tags: + - Content Type /v1/contenttype/page: get: description: Returns a list of content type objects based on the filtering criteria. diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java index 77367447a41e..c6b0b2ebf6a1 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java @@ -65,6 +65,7 @@ com.dotcms.rest.api.v1.authentication.ResetPasswordResourceIntegrationTest.class, com.dotcms.rest.api.v1.authentication.CreateJsonWebTokenResourceIntegrationTest.class, com.dotcms.rest.api.v1.relationships.RelationshipsResourceTest.class, + com.dotcms.rest.api.v1.contenttype.ContentTypeResourceUpdateMetadataTest.class, com.dotcms.rest.api.v2.contenttype.FieldResourceTest.class, com.dotcms.rest.api.v3.contenttype.FieldResourceTest.class, com.dotcms.rest.api.v3.contenttype.MoveFieldFormTest.class, diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResourceUpdateMetadataTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResourceUpdateMetadataTest.java new file mode 100644 index 000000000000..69d6534eaeb2 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResourceUpdateMetadataTest.java @@ -0,0 +1,430 @@ +package com.dotcms.rest.api.v1.contenttype; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.contenttype.business.ContentTypeAPI; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.contenttype.model.type.ContentTypeBuilder; +import com.dotcms.datagen.ContentTypeDataGen; +import com.dotcms.datagen.TestUserUtils; +import com.dotcms.mock.request.MockAttributeRequest; +import com.dotcms.mock.request.MockHeaderRequest; +import com.dotcms.mock.request.MockHttpRequestIntegrationTest; +import com.dotcms.mock.request.MockSessionRequest; +import com.dotcms.rest.EmptyHttpResponse; +import com.dotcms.rest.ErrorEntity; +import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.exception.ForbiddenException; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.PaginationUtil; +import com.dotcms.workflow.helper.WorkflowHelper; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.PermissionAPI; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.liferay.portal.model.User; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Integration tests for {@link ContentTypeResource#updateContentTypeMetadata}. + * + *

Covers the basic merge/remove contract as well as the + * {@code normalizeStyleEditorSchemaToString} path, which coerces a + * {@code DOT_STYLE_EDITOR_SCHEMA} value received as a JSON object into the JSON string + * representation that downstream page-rendering code expects. + */ +public class ContentTypeResourceUpdateMetadataTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static ContentTypeResource resource; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + resource = new ContentTypeResource(); + } + + // ------------------------------------------------------------------------- + // Basic merge contract + // ------------------------------------------------------------------------- + + /** + * Given: a content type with no metadata + * When: PATCH is called with a new key/value + * Then: the key is present in the saved metadata + */ + @Test + public void givenNewKey_whenPatch_thenKeyIsAdded() throws Exception { + final ContentType ct = new ContentTypeDataGen().nextPersisted(); + try { + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), + Map.of("MY_CUSTOM_KEY", "hello")); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("hello", getMetadata(response).get("MY_CUSTOM_KEY")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + /** + * Given: a content type with an existing metadata key + * When: PATCH is called with the same key and a different value + * Then: the key is overwritten with the new value + */ + @Test + public void givenExistingKey_whenPatch_thenKeyIsOverwritten() throws Exception { + final ContentType ct = persistWithMetadata(Map.of("MY_KEY", "original")); + try { + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), + Map.of("MY_KEY", "updated")); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("updated", getMetadata(response).get("MY_KEY")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + /** + * Given: a content type with an existing metadata key + * When: PATCH is called with that key set to {@code null} + * Then: the key is removed from the saved metadata + */ + @Test + public void givenExistingKey_whenPatchWithNullValue_thenKeyIsRemoved() throws Exception { + final ContentType ct = persistWithMetadata(Map.of("MY_KEY", "value")); + try { + final Map patch = new HashMap<>(); + patch.put("MY_KEY", null); + + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), patch); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertFalse("Key should have been removed", getMetadata(response).containsKey("MY_KEY")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + /** + * Given: a content type with an existing metadata key + * When: PATCH is called with a different, unrelated key + * Then: the original key is preserved alongside the new one + */ + @Test + public void givenExistingMetadata_whenPatchAddsNewKey_thenUnrelatedKeysArePreserved() throws Exception { + final ContentType ct = persistWithMetadata(Map.of("EXISTING_KEY", "preserved")); + try { + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), + Map.of("NEW_KEY", "new_value")); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + final Map saved = getMetadata(response); + assertEquals("preserved", saved.get("EXISTING_KEY")); + assertEquals("new_value", saved.get("NEW_KEY")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + /** + * Given: a content type with metadata + * When: PATCH is called with an empty map + * Then: the response is 200 and the existing metadata is returned unchanged + */ + @Test + public void givenEmptyPatch_whenPatch_thenContentTypeIsReturnedUnchanged() throws Exception { + final ContentType ct = persistWithMetadata(Map.of("EXISTING", "value")); + try { + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), Map.of()); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("value", getMetadata(response).get("EXISTING")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + /** + * Given: an ID that does not correspond to any content type + * When: PATCH is called + * Then: the response is HTTP 200 with {@code CONTENT_TYPE_NOT_FOUND} in the errors array + */ + @Test + public void givenUnknownId_whenPatch_thenReturns404WithErrorInBody() { + final String unknownId = UUID.randomUUID().toString(); + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), + unknownId, Map.of("KEY", "value")); + + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); + final List errors = getErrors(response); + assertFalse("errors array should not be empty", errors.isEmpty()); + assertEquals("CONTENT_TYPE_NOT_FOUND", errors.get(0).getErrorCode()); + assertEquals(unknownId, errors.get(0).getFieldName()); + } + + /** + * Given: a user who does not have edit permission on the content type + * When: PATCH is called + * Then: the response is 403 Forbidden + */ + @Test + public void givenUserWithoutEditPermission_whenPatch_thenReturns403() throws Exception { + final ContentType ct = new ContentTypeDataGen().nextPersisted(); + try { + // chrisPublisher has Publisher role but no EDIT permission on newly created content types + final User limitedUser = TestUserUtils.getChrisPublisherUser(); + + final WebResource mockedWebResource = mock(WebResource.class); + final InitDataObject dataObject = mock(InitDataObject.class); + when(dataObject.getUser()).thenReturn(limitedUser); + when(mockedWebResource.init(any(WebResource.InitBuilder.class))).thenReturn(dataObject); + + final ContentTypeResource limitedResource = new ContentTypeResource( + ContentTypeHelper.getInstance(), + mockedWebResource, + mock(PaginationUtil.class), + WorkflowHelper.getInstance(), + mock(PermissionAPI.class)); + + try { + limitedResource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), + Map.of("KEY", "value")); + org.junit.Assert.fail("Expected ForbiddenException to be thrown"); + } catch (final ForbiddenException e) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), + e.getResponse().getStatus()); + } + } finally { + ContentTypeDataGen.remove(ct); + } + } + + // ------------------------------------------------------------------------- + // normalizeStyleEditorSchemaToString paths + // ------------------------------------------------------------------------- + + /** + * Given: {@code DOT_STYLE_EDITOR_SCHEMA} is sent as a JSON object (i.e. a {@link Map}), + * which is what Jackson produces when the client does not stringify the value + * When: PATCH is called + * Then: the stored value is a JSON string (not a Map), and the string round-trips back + * to the original object correctly + */ + @Test + public void givenStyleEditorSchemaAsJsonObject_whenPatch_thenSchemaIsStoredAsString() + throws Exception { + final Map schemaObject = Map.of( + "contentType", "myType", + "sections", List.of(Map.of("title", "Layout", "fields", List.of())) + ); + + final ContentType ct = new ContentTypeDataGen().nextPersisted(); + try { + final Map patch = new HashMap<>(); + patch.put("DOT_STYLE_EDITOR_SCHEMA", schemaObject); + + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), patch); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + final Object storedValue = getMetadata(response).get("DOT_STYLE_EDITOR_SCHEMA"); + assertNotNull("DOT_STYLE_EDITOR_SCHEMA should be present", storedValue); + assertTrue("DOT_STYLE_EDITOR_SCHEMA should have been normalized to a String", + storedValue instanceof String); + + final Map parsedBack = MAPPER.readValue((String) storedValue, Map.class); + assertEquals("myType", parsedBack.get("contentType")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + /** + * Given: {@code DOT_STYLE_EDITOR_SCHEMA} is sent already as a JSON string + * When: PATCH is called + * Then: the stored value equals the original string unchanged + */ + @Test + public void givenStyleEditorSchemaAsString_whenPatch_thenSchemaPassesThroughUnchanged() + throws Exception { + final String schemaStr = "{\"contentType\":\"myType\",\"sections\":[]}"; + final ContentType ct = new ContentTypeDataGen().nextPersisted(); + try { + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), + Map.of("DOT_STYLE_EDITOR_SCHEMA", schemaStr)); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(schemaStr, getMetadata(response).get("DOT_STYLE_EDITOR_SCHEMA")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + /** + * Given: a content type that already has {@code DOT_STYLE_EDITOR_SCHEMA} in its metadata + * When: PATCH is called with that key set to {@code null} + * Then: the key is removed from the saved metadata + */ + @Test + public void givenStyleEditorSchemaPresent_whenPatchWithNullValue_thenKeyIsRemoved() + throws Exception { + final ContentType ct = persistWithMetadata( + Map.of("DOT_STYLE_EDITOR_SCHEMA", "{\"contentType\":\"myType\"}")); + try { + final Map patch = new HashMap<>(); + patch.put("DOT_STYLE_EDITOR_SCHEMA", null); + + final Response response = resource.updateContentTypeMetadata( + getHttpRequest(), new EmptyHttpResponse(), ct.id(), patch); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertFalse("DOT_STYLE_EDITOR_SCHEMA should have been removed", + getMetadata(response).containsKey("DOT_STYLE_EDITOR_SCHEMA")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + // ------------------------------------------------------------------------- + // Concurrency — striped-lock protection + // ------------------------------------------------------------------------- + + /** + * Given: two concurrent PATCH requests on the same Content Type, each adding a different key + * When: both execute simultaneously + * Then: both keys are present in the final metadata — no lost update + * + *

Without the striped lock in {@link ContentTypeHelper#mergeAndSaveMetadata}, the + * read-modify-write sequence is not atomic: both threads could read the same stale snapshot, + * merge their key independently, and the last writer would overwrite the first one's key. + * The lock serializes the two operations so the second thread always re-reads the result + * written by the first. + */ + @Test + public void givenTwoConcurrentPatches_whenBothExecute_thenNeitherKeyIsLost() throws Exception { + final ContentType ct = new ContentTypeDataGen().nextPersisted(); + try { + final ContentTypeAPI contentTypeAPI = + APILocator.getContentTypeAPI(APILocator.getUserAPI().getSystemUser(), true); + final ContentTypeHelper helper = ContentTypeHelper.getInstance(); + + // Latch that releases both threads at exactly the same moment to maximize + // the chance of hitting the race window + final CountDownLatch startGate = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(2); + final AtomicReference firstError = new AtomicReference<>(); + + final Runnable patchA = () -> { + try { + startGate.await(); + helper.mergeAndSaveMetadata(ct.id(), Map.of("KEY_A", "value_a"), contentTypeAPI); + } catch (final Throwable t) { + firstError.compareAndSet(null, t); + } finally { + doneLatch.countDown(); + } + }; + + final Runnable patchB = () -> { + try { + startGate.await(); + helper.mergeAndSaveMetadata(ct.id(), Map.of("KEY_B", "value_b"), contentTypeAPI); + } catch (final Throwable t) { + firstError.compareAndSet(null, t); + } finally { + doneLatch.countDown(); + } + }; + + new Thread(patchA, "patch-thread-A").start(); + new Thread(patchB, "patch-thread-B").start(); + startGate.countDown(); // release both simultaneously + + assertTrue("Patch threads did not complete within 10 s", + doneLatch.await(10, TimeUnit.SECONDS)); + assertNull("A patch thread threw: " + firstError.get(), firstError.get()); + + // Both keys must survive — if they don't, a lost-update occurred + final ContentType result = contentTypeAPI.find(ct.id()); + assertEquals("value_a", result.metadata().get("KEY_A")); + assertEquals("value_b", result.metadata().get("KEY_B")); + } finally { + ContentTypeDataGen.remove(ct); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Creates and persists a content type with the given metadata map. + */ + private static ContentType persistWithMetadata(final Map metadata) + throws Exception { + final ContentType base = new ContentTypeDataGen().nextPersisted(); + final ContentType withMeta = ContentTypeBuilder.builder(base).metadata(metadata).build(); + return APILocator.getContentTypeAPI(APILocator.getUserAPI().getSystemUser(), true) + .save(withMeta); + } + + /** + * Extracts the {@code metadata} map from a {@link ResponseEntityContentTypeDetailView} + * response body. + */ + @SuppressWarnings("unchecked") + private static Map getMetadata(final Response response) { + final Map entity = + (Map) ((ResponseEntityView) response.getEntity()).getEntity(); + return (Map) entity.get("metadata"); + } + + /** Extracts the {@code errors} list from a response body. */ + private static List getErrors(final Response response) { + return ((ResponseEntityView) response.getEntity()).getErrors(); + } + + private static HttpServletRequest getHttpRequest() { + final MockHeaderRequest request = new MockHeaderRequest( + new MockSessionRequest( + new MockAttributeRequest( + new MockHttpRequestIntegrationTest("localhost", "/").request() + ).request() + ).request() + ); + request.setHeader("Authorization", + "Basic " + Base64.getEncoder().encodeToString( + "admin@dotcms.com:admin".getBytes())); + return request; + } +} \ No newline at end of file diff --git a/dotcms-postman/src/main/resources/postman/Define_Contentlets_StyleProperties.postman_collection.json b/dotcms-postman/src/main/resources/postman/Define_Contentlets_StyleProperties.postman_collection.json index 23540921c87d..b87cbda50e1d 100644 --- a/dotcms-postman/src/main/resources/postman/Define_Contentlets_StyleProperties.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/Define_Contentlets_StyleProperties.postman_collection.json @@ -2435,21 +2435,40 @@ "name": "Traditional Style Editor", "item": [ { - "name": "Build Content Type Schema", + "name": "Save Content Type Schema", "event": [ { "listen": "test", "script": { "exec": [ + "const contentTypeName = pm.collectionVariables.get(\"contentTypeName\");", + "", + "const entity = pm.response.json().entity;", + "", "pm.test(\"Status code is 200\", function () {", " pm.response.to.have.status(200);", "});", "", - "const entity = pm.response.json().entity;", - "pm.expect(entity).to.not.be.null;", + "pm.test(\"Should have the correct content type schema\", function () {", + " const schemaStr = entity.metadata[\"DOT_STYLE_EDITOR_SCHEMA\"];", + " pm.expect(schemaStr).to.exist;", + " const schema = JSON.parse(schemaStr);", + " pm.expect(schema.contentType).eq(contentTypeName);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const contentTypeName = pm.collectionVariables.get(\"contentTypeName\");", "", "const SCHEMA = {", - " \"contentType\": entity.name,", + " \"contentType\": contentTypeName,", " \"sections\": [", " {", " \"title\": \"Layout\",", @@ -2475,69 +2494,7 @@ " ]", "};", "", - "pm.test(\"Append SCHEMA in the contentType metadata\", function () {", - " const contentTypeInfo = { ...entity, metadata: { \"DOT_STYLE_EDITOR_SCHEMA\": JSON.stringify(SCHEMA) } };", - " pm.expect(contentTypeInfo.metadata.DOT_STYLE_EDITOR_SCHEMA).to.exist;", - " pm.collectionVariables.set(\"contentTypeInfo\", JSON.stringify(contentTypeInfo));", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{jwt}}", - "type": "string" - } - ] - }, - "method": "GET", - "header": [], - "url": { - "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId}}", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "contenttype", - "id", - "{{contentTypeId}}" - ] - } - }, - "response": [] - }, - { - "name": "Save Content Type Schema", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "const contentTypeInfo = pm.collectionVariables.get(\"contentTypeInfo\");", - "const contentTypeName = pm.collectionVariables.get(\"contentTypeName\");", - "", - "const entity = pm.response.json().entity;", - "", - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Should have the correct content type schema\", function () {", - " const schemaStr = JSON.parse(contentTypeInfo).metadata[\"DOT_STYLE_EDITOR_SCHEMA\"];", - " const schema = JSON.parse(schemaStr);", - "", - " pm.expect(schema.contentType).to.eql(contentTypeName);", - "});" + "pm.collectionVariables.set(\"styleSchema\", JSON.stringify(SCHEMA));" ], "type": "text/javascript", "packages": {}, @@ -2546,7 +2503,7 @@ } ], "request": { - "method": "PUT", + "method": "PATCH", "header": [ { "key": "Accept", @@ -2615,7 +2572,7 @@ ], "body": { "mode": "raw", - "raw": "{{contentTypeInfo}}", + "raw": "{\n \"DOT_STYLE_EDITOR_SCHEMA\": {{styleSchema}}\n}", "options": { "raw": { "language": "json" @@ -2623,7 +2580,7 @@ } }, "url": { - "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId}}", + "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId}}/metadata", "host": [ "{{serverURL}}" ], @@ -2632,7 +2589,8 @@ "v1", "contenttype", "id", - "{{contentTypeId}}" + "{{contentTypeId}}", + "metadata" ] } }, @@ -3040,21 +2998,40 @@ "response": [] }, { - "name": "Build Content Type Schema 2", + "name": "Save Content Type Schema 2", "event": [ { "listen": "test", "script": { "exec": [ + "const contentTypeName = pm.collectionVariables.get(\"contentTypeName_2\");", + "", + "const entity = pm.response.json().entity;", + "", "pm.test(\"Status code is 200\", function () {", " pm.response.to.have.status(200);", "});", "", - "const entity = pm.response.json().entity;", - "pm.expect(entity).to.not.be.null;", + "pm.test(\"Should have the correct content type schema\", function () {", + " const schemaStr = entity.metadata[\"DOT_STYLE_EDITOR_SCHEMA\"];", + " pm.expect(schemaStr).to.exist;", + " const schema = JSON.parse(schemaStr);", + " pm.expect(schema.contentType).eq(contentTypeName);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const contentTypeName = pm.collectionVariables.get(\"contentTypeName_2\");", "", "const SCHEMA = {", - " \"contentType\": entity.name,", + " \"contentType\": contentTypeName,", " \"sections\": [", " {", " \"title\": \"Typography\",", @@ -3081,69 +3058,7 @@ " ]", "};", "", - "pm.test(\"Append SCHEMA in the contentType metadata\", function () {", - " const contentTypeInfo = { ...entity, metadata: { \"DOT_STYLE_EDITOR_SCHEMA\": JSON.stringify(SCHEMA) } };", - " pm.expect(contentTypeInfo.metadata.DOT_STYLE_EDITOR_SCHEMA).to.exist;", - " pm.collectionVariables.set(\"contentTypeInfo_2\", JSON.stringify(contentTypeInfo));", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{jwt}}", - "type": "string" - } - ] - }, - "method": "GET", - "header": [], - "url": { - "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId_2}}", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "contenttype", - "id", - "{{contentTypeId_2}}" - ] - } - }, - "response": [] - }, - { - "name": "Save Content Type Schema 2", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "const contentTypeInfo = pm.collectionVariables.get(\"contentTypeInfo_2\");", - "const contentTypeName = pm.collectionVariables.get(\"contentTypeName_2\");", - "", - "const entity = pm.response.json().entity;", - "", - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Should have the correct content type schema\", function () {", - " const schemaStr = JSON.parse(contentTypeInfo).metadata[\"DOT_STYLE_EDITOR_SCHEMA\"];", - " const schema = JSON.parse(schemaStr);", - "", - " pm.expect(schema.contentType).to.eql(contentTypeName);", - "});" + "pm.collectionVariables.set(\"styleSchema_2\", JSON.stringify(SCHEMA));" ], "type": "text/javascript", "packages": {}, @@ -3152,7 +3067,7 @@ } ], "request": { - "method": "PUT", + "method": "PATCH", "header": [ { "key": "Accept", @@ -3221,7 +3136,7 @@ ], "body": { "mode": "raw", - "raw": "{{contentTypeInfo_2}}", + "raw": "{\n \"DOT_STYLE_EDITOR_SCHEMA\": {{styleSchema_2}}\n}", "options": { "raw": { "language": "json" @@ -3229,7 +3144,7 @@ } }, "url": { - "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId_2}}", + "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId_2}}/metadata", "host": [ "{{serverURL}}" ], @@ -3238,7 +3153,8 @@ "v1", "contenttype", "id", - "{{contentTypeId_2}}" + "{{contentTypeId_2}}", + "metadata" ] } }, @@ -3400,16 +3316,16 @@ " pm.expect(schemaScript).to.include(\"dotUVE.registerStyleEditorSchemas\");", "});", "", - "// We have 2 contentlets rendered in the page, but both have the same Content Type so we expect only one SCHEMA", - "pm.test(\"Validate SCHEMA is not duplicated in registerStyleEditorSchemas\", function () {", - " // Regular expression to find the script that contains 'initDotUVE'", + "// We have 2 contentlets of different Content Types in the page, so we expect 2 distinct SCHEMAs", + "pm.test(\"Validate 2 distinct schemas are present in registerStyleEditorSchemas\", function () {", + " // Regular expression to find all contentType entries in the registerStyleEditorSchemas call", " const contentTypeRegex = /\"contentType\":\"(.*?)\"/g;", "", - " // Gets all schemas pased to the registerStyleEditorSchemas", + " // Gets all schemas passed to the registerStyleEditorSchemas", " const schemas = pageRendered.match(contentTypeRegex);", " ", " pm.expect(schemas).to.exist;", - " pm.expect(schemas.length).to.equal(2, `Expected 2 schema but found ${schemas.length}`);", + " pm.expect(schemas.length).to.equal(2, `Expected 2 schemas but found ${schemas.length}`);", "});" ], "type": "text/javascript", @@ -3641,31 +3557,31 @@ "value": "" }, { - "key": "contentTypeInfo", + "key": "contentTypeId_2", "value": "" }, { - "key": "contentTypeId_2", + "key": "contentTypeVarName_2", "value": "" }, { - "key": "contentTypeVarName_2", + "key": "Contentlet_3", "value": "" }, { - "key": "contentTypeInfo_2", + "key": "contentTypeName", "value": "" }, { - "key": "Contentlet_3", + "key": "contentTypeName_2", "value": "" }, { - "key": "contentTypeName", + "key": "styleSchema", "value": "" }, { - "key": "contentTypeName_2", + "key": "styleSchema_2", "value": "" } ]