diff --git a/NEWS.md b/NEWS.md index 9576ec83..15aa5778 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,7 @@ * Update visibility hidden reason handling ([MODKBEKBJ-823](https://folio-org.atlassian.net/browse/MODKBEKBJ-823)) * Remove HTML validation for package's custom display name field ([MODKBEKBJ-855](https://folio-org.atlassian.net/browse/MODKBEKBJ-855)) * Add access types to eholdings/providers/{id}/packages response ([MODKBEKBJ-839](https://folio-org.atlassian.net/browse/MODKBEKBJ-839)) +* Sanitize HTML content for the package description field ([MODKBEKBJ-860](https://folio-org.atlassian.net/browse/MODKBEKBJ-860)) ### Bug fixes * Fix offset handling when retrieving holdings from HoldingsIQ. ([MODKBEKBJ-825](https://folio-org.atlassian.net/browse/MODKBEKBJ-825)) diff --git a/pom.xml b/pom.xml index 992e03f7..706e3326 100644 --- a/pom.xml +++ b/pom.xml @@ -52,6 +52,7 @@ 1.18.48 5.12.0 3.20.0 + 1.23.2 ${basedir}/ramls types/** @@ -222,6 +223,11 @@ commons-lang3 ${commons-lang3.version} + + org.jsoup + jsoup + ${jsoup.version} + diff --git a/src/main/java/org/folio/rest/converter/packages/CommonPackagePutRequestConverter.java b/src/main/java/org/folio/rest/converter/packages/CommonPackagePutRequestConverter.java index e7d69011..87ee7106 100644 --- a/src/main/java/org/folio/rest/converter/packages/CommonPackagePutRequestConverter.java +++ b/src/main/java/org/folio/rest/converter/packages/CommonPackagePutRequestConverter.java @@ -10,11 +10,18 @@ import org.folio.holdingsiq.model.Visibility; import org.folio.rest.jaxrs.model.PackagePutDataAttributes; import org.folio.rest.jaxrs.model.PackageVisibility; +import org.folio.service.sanitizer.Sanitizer; public abstract class CommonPackagePutRequestConverter { static final String HIDDEN_BY_CUSTOMER = "Hidden by Customer"; + private final Sanitizer htmlSanitizer; + + protected CommonPackagePutRequestConverter(Sanitizer htmlSanitizer) { + this.htmlSanitizer = htmlSanitizer; + } + protected PackagePut.PackagePutBuilder convertCommonAttributes(PackagePutDataAttributes attributes) { var builder = PackagePut.builder(); @@ -45,7 +52,7 @@ protected Visibility convertVisibility(PackageVisibility pv) { private void convertSimpleFields(PackagePutDataAttributes attributes, PackagePut.PackagePutBuilder builder) { builder.isSelected(attributes.getIsSelected()); builder.isFullPackage(attributes.getIsFullPackage()); - builder.customDescription(attributes.getCustomDescription()); + builder.customDescription(htmlSanitizer.sanitize(attributes.getCustomDescription())); builder.customDisplayName(attributes.getCustomDisplayName()); builder.packageFreeAccess(attributes.getIsFreeAccess()); builder.packageUrl(attributes.getUrl()); diff --git a/src/main/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverter.java b/src/main/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverter.java index 5de95600..409ba8c8 100644 --- a/src/main/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverter.java +++ b/src/main/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverter.java @@ -12,12 +12,19 @@ import org.folio.rest.jaxrs.model.PackageAltName; import org.folio.rest.jaxrs.model.PackagePostRequest; import org.folio.rest.jaxrs.model.ProxyDto; +import org.folio.service.sanitizer.Sanitizer; import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; @Component public class CustomPackagePostRequestConverter implements Converter { + private final Sanitizer htmlSanitizer; + + public CustomPackagePostRequestConverter(Sanitizer htmlSanitizer) { + this.htmlSanitizer = htmlSanitizer; + } + @Override public PackagePost convert(PackagePostRequest postPackageBody) { var data = postPackageBody.getData(); @@ -25,7 +32,7 @@ public PackagePost convert(PackagePostRequest postPackageBody) { return PackagePost.builder() .customAltNames(mapItemsNullable(attributes.getCustomAltNames(), this::convertAlternateName)) .coverage(convertCoverageDates(attributes.getCustomCoverage())) - .customDescription(attributes.getCustomDescription()) + .customDescription(htmlSanitizer.sanitize(attributes.getCustomDescription())) .customDisplayName(attributes.getCustomDisplayName()) .contentType(CONTENT_TYPE_TO_RMAPI_CODE.getOrDefault(attributes.getContentType(), 6)) .packageName(attributes.getName()) diff --git a/src/main/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverter.java b/src/main/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverter.java index afac2ee2..d17ca8e5 100644 --- a/src/main/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverter.java +++ b/src/main/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverter.java @@ -4,6 +4,7 @@ import org.folio.holdingsiq.model.PackagePut; import org.folio.rest.jaxrs.model.PackagePutRequest; +import org.folio.service.sanitizer.Sanitizer; import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; @@ -12,6 +13,10 @@ public class CustomPackagePutRequestConverter extends CommonPackagePutRequestConverter implements Converter { + public CustomPackagePutRequestConverter(Sanitizer htmlSanitizer) { + super(htmlSanitizer); + } + @Override public PackagePut convert(PackagePutRequest request) { var attributes = request.getData().getAttributes(); diff --git a/src/main/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverter.java b/src/main/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverter.java index 81faf718..3943089e 100644 --- a/src/main/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverter.java +++ b/src/main/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverter.java @@ -3,6 +3,7 @@ import org.folio.holdingsiq.model.PackagePut; import org.folio.holdingsiq.model.TokenInfo; import org.folio.rest.jaxrs.model.PackagePutRequest; +import org.folio.service.sanitizer.Sanitizer; import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; @@ -11,6 +12,10 @@ public class ManagedPackagePutRequestConverter extends CommonPackagePutRequestConverter implements Converter { + public ManagedPackagePutRequestConverter(Sanitizer htmlSanitizer) { + super(htmlSanitizer); + } + @Override public PackagePut convert(PackagePutRequest request) { var attributes = request.getData().getAttributes(); diff --git a/src/main/java/org/folio/rest/impl/EholdingsPackagesImpl.java b/src/main/java/org/folio/rest/impl/EholdingsPackagesImpl.java index 449789c8..49e4ade0 100644 --- a/src/main/java/org/folio/rest/impl/EholdingsPackagesImpl.java +++ b/src/main/java/org/folio/rest/impl/EholdingsPackagesImpl.java @@ -1,12 +1,8 @@ package org.folio.rest.impl; -import static java.util.concurrent.CompletableFuture.completedFuture; -import static java.util.stream.Collectors.toMap; import static org.folio.common.ListUtils.parseByComma; -import static org.folio.db.RowSetUtils.toUUID; import static org.folio.rest.util.ExceptionMappers.error400NotFoundMapper; import static org.folio.rest.util.ExceptionMappers.error422InputValidationMapper; -import static org.folio.rest.util.IdParser.packageIdToString; import static org.folio.rest.util.IdParser.parsePackageId; import static org.folio.rest.util.RestConstants.JSONAPI; import static org.folio.rest.util.RestConstants.TAGS_TYPE; @@ -16,41 +12,17 @@ import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.function.Function; import javax.ws.rs.NotFoundException; import javax.ws.rs.core.Response; -import org.apache.commons.lang3.BooleanUtils; -import org.folio.cache.VertxCache; -import org.folio.config.cache.VendorIdCacheKey; -import org.folio.holdingsiq.model.CustomerResources; -import org.folio.holdingsiq.model.PackageData; import org.folio.holdingsiq.model.PackageId; -import org.folio.holdingsiq.model.PackagePost; -import org.folio.holdingsiq.model.PackagePut; -import org.folio.holdingsiq.model.Packages; import org.folio.holdingsiq.model.RequestContext; -import org.folio.holdingsiq.model.Titles; import org.folio.holdingsiq.service.exception.ResourceNotFoundException; -import org.folio.properties.common.SearchProperties; -import org.folio.repository.RecordKey; -import org.folio.repository.RecordType; -import org.folio.repository.accesstypes.DbAccessType; -import org.folio.repository.packages.DbPackage; -import org.folio.repository.packages.PackageRepository; -import org.folio.repository.tag.DbTag; -import org.folio.repository.tag.TagRepository; import org.folio.rest.annotations.Validate; import org.folio.rest.aspect.HandleValidationErrors; -import org.folio.rest.converter.common.ConverterConsts; -import org.folio.rest.converter.packages.PackageRequestConvertionService; import org.folio.rest.exception.InputValidationException; -import org.folio.rest.jaxrs.model.AccessType; import org.folio.rest.jaxrs.model.Package; import org.folio.rest.jaxrs.model.PackageBulkFetchCollection; import org.folio.rest.jaxrs.model.PackageCollection; @@ -62,7 +34,6 @@ import org.folio.rest.jaxrs.model.PackageTagsItem; import org.folio.rest.jaxrs.model.PackageTagsPutRequest; import org.folio.rest.jaxrs.model.ResourceCollection; -import org.folio.rest.jaxrs.model.Tags; import org.folio.rest.jaxrs.resource.EholdingsPackages; import org.folio.rest.model.filter.AccessTypeFilter; import org.folio.rest.model.filter.PackageRecordFilter; @@ -71,60 +42,28 @@ import org.folio.rest.util.ErrorHandler; import org.folio.rest.util.ErrorUtil; import org.folio.rest.util.template.RmApiTemplate; -import org.folio.rest.util.template.RmApiTemplateContext; import org.folio.rest.util.template.RmApiTemplateFactory; -import org.folio.rest.validator.packages.PackageValidationService; -import org.folio.rmapi.result.PackageResult; -import org.folio.rmapi.result.TitleCollectionResult; -import org.folio.rmapi.result.TitleResult; -import org.folio.service.accesstypes.AccessTypeMappingsService; -import org.folio.service.accesstypes.AccessTypesService; import org.folio.service.kbcredentials.UserKbCredentialsService; import org.folio.service.loader.FilteredEntitiesLoader; -import org.folio.service.loader.RelatedEntitiesLoader; +import org.folio.service.packages.PackageService; import org.folio.spring.SpringContextUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.core.convert.converter.Converter; @SuppressWarnings("java:S6813") public class EholdingsPackagesImpl implements EholdingsPackages { private static final String PACKAGE_NOT_FOUND_MESSAGE = "Package not found"; - private static final String INVALID_PACKAGE_TITLE = "Package cannot be deleted"; - private static final String INVALID_PACKAGE_DETAILS = "Invalid package"; - private static final String PACKAGE_IS_CUSTOM_NOT_MATCHED = "Package isCustom not matched"; - private static final String PACKAGE_IS_CUSTOM_NOT_MATCHED_DETAILS = "Package isCustom: %s"; - - @Autowired - private PackageRequestConvertionService requestConvertionService; @Autowired - private PackageValidationService validationService; + private PackageService packageService; @Autowired private RmApiTemplateFactory templateFactory; @Autowired - @Qualifier("vendorIdCache") - private VertxCache vendorIdCache; - @Autowired - private TagRepository tagRepository; - @Autowired - private PackageRepository packageRepository; - @Autowired - private Converter titleCollectionConverter; - @Autowired - private AccessTypesService accessTypesService; - @Autowired - private AccessTypeMappingsService accessTypeMappingsService; - @Autowired - private RelatedEntitiesLoader relatedEntitiesLoader; - @Autowired private FilteredEntitiesLoader filteredEntitiesLoader; @Autowired @Qualifier("securedUserCredentialsService") private UserKbCredentialsService userKbCredentialsService; - @Autowired - private SearchProperties searchProperties; public EholdingsPackagesImpl() { SpringContextUtil.autowireDependencies(this, Vertx.currentContext()); @@ -164,15 +103,13 @@ public void getEholdingsPackages(String filterCustom, String q, String queryFiel template.requestAction(context -> filteredEntitiesLoader .fetchPackagesByAccessTypeFilter(AccessTypeFilter.from(filter), context)); } else { - template - .requestAction(context -> { - if (Boolean.TRUE.equals(filter.resolveFilterCustom())) { - return getCustomProviderId(context) - .thenCompose(providerId -> retrievePackages(providerId, filter, context)); - } else { - return retrievePackages(null, filter, context); - } - }); + template.requestAction(context -> { + if (Boolean.TRUE.equals(filter.resolveFilterCustom())) { + return packageService.getCustomProviderIdAndRetrievePackages(filter, context); + } else { + return packageService.retrievePackages(null, filter, context); + } + }); } template.executeWithResult(PackageCollection.class); @@ -182,20 +119,9 @@ public void getEholdingsPackages(String filterCustom, String q, String queryFiel @HandleValidationErrors public void postEholdingsPackages(String contentType, PackagePostRequest entity, Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { - validationService.validateCustomPackagePostRequest(entity); RmApiTemplate template = templateFactory.createTemplate(okapiHeaders, asyncResultHandler); - PackagePost packagePost = requestConvertionService.convertCustomPackagePostRequest(entity); - String accessTypeId = entity.getData().getAttributes().getAccessTypeId(); - - if (accessTypeId == null) { - template.requestAction(context -> postCustomPackage(packagePost, context)); - } else { - template.requestAction(context -> accessTypesService.findByCredentialsAndAccessTypeId(context.getCredentialsId(), - accessTypeId, false, okapiHeaders) - .thenCompose(accessType -> postCustomPackage(packagePost, context) - .thenCompose(packageResult -> updateAccessTypeMapping(accessType, packageResult, context)))); - } + template.requestAction(context -> packageService.createCustomPackage(entity, context)); template .addErrorMapper(NotFoundException.class, error400NotFoundMapper()) @@ -210,19 +136,8 @@ public void getEholdingsPackagesByPackageId(String packageId, String include, Ma List includedObjects = parseByComma(include); templateFactory.createTemplate(okapiHeaders, asyncResultHandler) - .requestAction(context -> - context.getPackagesService().retrievePackage(parsedPackageId, includedObjects) - .thenCompose(packageResult -> { - RecordKey recordKey = RecordKey.builder() - .recordId(packageIdToString(parsedPackageId)) - .recordType(RecordType.PACKAGE) - .build(); - return CompletableFuture.allOf( - relatedEntitiesLoader.loadAccessType(packageResult, recordKey, context), - relatedEntitiesLoader.loadTags(packageResult, recordKey, context)) - .thenApply(v -> packageResult); - }) - ) + .requestAction(context -> packageService.retrievePackageWithRelatedData(parsedPackageId, includedObjects, + context)) .executeWithResult(Package.class); } @@ -232,20 +147,8 @@ public void putEholdingsPackagesByPackageId(String packageId, String contentType Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { PackageId parsedPackageId = parsePackageId(packageId); - var packageIdPart = parsedPackageId.packageIdPart(); templateFactory.createTemplate(okapiHeaders, asyncResultHandler) - .requestAction(context -> context.getPackagesService().retrievePackage(packageIdPart) - .thenCompose(packageData -> fetchAccessType(entity, context) - .thenCompose(accessType -> processUpdateRequest(entity, packageData, context) - .thenCompose(voidEntity -> { - CompletableFuture future = context.getPackagesService().retrievePackage(packageIdPart); - return handleDeletedPackage(future, parsedPackageId, context); - }) - .thenApply(packageById -> new PackageResult(packageById, null, null)) - .thenCompose(packageResult -> updateAccessTypeMapping(accessType, packageResult, context)) - ) - ) - ) + .requestAction(context -> packageService.updatePackage(parsedPackageId, entity, context)) .addErrorMapper(NotFoundException.class, error400NotFoundMapper()) .addErrorMapper(InputValidationException.class, error422InputValidationMapper()) .executeWithResult(Package.class); @@ -257,24 +160,15 @@ public void deleteEholdingsPackagesByPackageId(String packageId, Map> asyncResultHandler, Context vertxContext) { PackageId parsedPackageId = parsePackageId(packageId); - var packageIdPart = parsedPackageId.packageIdPart(); templateFactory.createTemplate(okapiHeaders, asyncResultHandler) - .requestAction(context -> - context.getPackagesService().retrievePackage(packageIdPart) - .thenCompose(packageData -> { - if (BooleanUtils.isNotTrue(packageData.getIsCustom())) { - throw new InputValidationException(INVALID_PACKAGE_TITLE, INVALID_PACKAGE_DETAILS); - } - return context.getPackagesService().deletePackage(packageIdPart) - .thenCompose(v -> deleteAssignedResources(parsedPackageId, context)); - })) + .requestAction(context -> packageService.deletePackage(parsedPackageId, context)) .execute(); } - @SuppressWarnings("checkstyle:MethodLength") @Override @Validate @HandleValidationErrors + @SuppressWarnings("checkstyle:MethodLength") public void getEholdingsPackagesResourcesByPackageId(String packageId, List filterTags, List filterAccessType, String filterSelected, String filterType, String filterName, String filterIsxn, @@ -308,7 +202,7 @@ public void getEholdingsPackagesResourcesByPackageId(String packageId, List filteredEntitiesLoader.fetchResourcesByAccessTypeFilter(AccessTypeFilter.from(filter), context)); } else { - template.requestAction(retrievePackageTitles(filter)); + template.requestAction(packageService.retrievePackageTitles(filter)); } template.addErrorMapper(ResourceNotFoundException.class, exception -> @@ -323,19 +217,13 @@ public void putEholdingsPackagesTagsByPackageId(String packageId, String content Handler> asyncResultHandler, Context vertxContext) { userKbCredentialsService.findByUser(headers) - .thenCompose(creds -> { - validationService.validatePackageTagsPutRequest(entity); - - PackageTagsDataAttributes attributes = entity.getData().getAttributes(); - - return updateTags(attributes.getTags(), createDbPackage(packageId, UUID.fromString(creds.getId()), attributes), + .thenCompose(creds -> packageService.updateTagsForPackage(entity, UUID.fromString(creds.getId()), packageId, new RequestContext(headers).getTenant()) - .thenAccept(o2 -> - asyncResultHandler - .handle( - Future.succeededFuture(PutEholdingsPackagesTagsByPackageIdResponse.respond200WithApplicationVndApiJson( - convertToPackageTags(attributes))))); - }) + .thenAccept(attributes -> + asyncResultHandler + .handle( + Future.succeededFuture(PutEholdingsPackagesTagsByPackageIdResponse.respond200WithApplicationVndApiJson( + convertToPackageTags(attributes)))))) .exceptionally(e -> { new ErrorHandler() .addInputValidation422Mapper() @@ -355,70 +243,6 @@ public void postEholdingsPackagesBulkFetch(String contentType, PackagePostBulkFe .executeWithResult(PackageBulkFetchCollection.class); } - private Function> retrievePackageTitles(ResourceFilter filter) { - return context -> { - var pkgId = filter.parsePackageId(); - return context.getTitlesService() - .retrieveTitles(pkgId.providerIdPart(), pkgId.packageIdPart(), filter.createFilterQuery(), - searchProperties.titlesSearchType(), filter.resolveSort(), filter.getPage(), filter.getCount()) - .thenApply(titles -> titleCollectionConverter.convert(titles)) - .thenCompose(loadResourceTags(context)) - .thenCompose(loadResourceAccessTypes(context)); - }; - } - - private CompletableFuture retrievePackages(Integer providerId, PackageRecordFilter filter, - RmApiTemplateContext context) { - var packageFilter = filter.toClientFilter(searchProperties); - var pageable = filter.toPageable(); - return providerId == null - ? context.getPackagesService().retrievePackages(packageFilter, pageable) - : context.getPackagesService().retrievePackages(providerId, packageFilter, pageable); - } - - private CompletableFuture updateAccessTypeMapping(AccessType accessType, - PackageResult packageResult, - RmApiTemplateContext context) { - String recordId = packageResult.getPackageData().getFullPackageId(); - return updateRecordMapping(accessType, recordId, context) - .thenApply(a -> { - packageResult.setAccessType(accessType); - return packageResult; - }); - } - - private CompletableFuture updateRecordMapping(AccessType accessType, String recordId, - RmApiTemplateContext context) { - return accessTypeMappingsService.update(accessType, recordId, RecordType.PACKAGE, context.getCredentialsId(), - context.getRequestContext().getHeaders()); - } - - private CompletableFuture fetchAccessType(PackagePutRequest entity, - RmApiTemplateContext context) { - String accessTypeId = entity.getData().getAttributes().getAccessTypeId(); - if (accessTypeId == null) { - return completedFuture(null); - } else { - return accessTypesService.findByCredentialsAndAccessTypeId(context.getCredentialsId(), accessTypeId, false, - context.getRequestContext().getHeaders()); - } - } - - private CompletableFuture postCustomPackage(PackagePost packagePost, RmApiTemplateContext context) { - return getCustomProviderId(context) - .thenCompose(id -> context.getPackagesService().postPackage(packagePost, id)) - .thenApply(packageById -> new PackageResult(packageById, null, null)); - } - - private DbPackage createDbPackage(String packageId, UUID credentialsId, PackageTagsDataAttributes attributes) { - return DbPackage.builder() - .id(parsePackageId(packageId)) - .credentialsId(credentialsId) - .name(attributes.getName()) - .contentType(ConverterConsts.CONTENT_TYPES.inverseBidiMap().get(attributes.getContentType())) - .build(); - } - private PackageTags convertToPackageTags(PackageTagsDataAttributes attributes) { return new PackageTags() .withData(new PackageTagsItem() @@ -426,152 +250,4 @@ private PackageTags convertToPackageTags(PackageTagsDataAttributes attributes) { .withAttributes(attributes)) .withJsonapi(JSONAPI); } - - private Function> loadResourceTags( - RmApiTemplateContext context) { - return titleCollection -> { - Map resourceIdToTitle = mapResourceIdToTitleResult(titleCollection); - - return tagRepository.findPerRecord(context.getRequestContext().getTenant(), - new ArrayList<>(resourceIdToTitle.keySet()), - RecordType.RESOURCE) - .thenApply(tagMap -> { - populateResourceTags(resourceIdToTitle, tagMap); - return titleCollection; - }); - }; - } - - private void populateResourceTags(Map resourceIdToTitle, Map> tagMap) { - tagMap.forEach((id, tags) -> { - TitleResult titleResult = resourceIdToTitle.get(id); - titleResult.setResourceTagList(tags); - }); - } - - private Function> loadResourceAccessTypes( - RmApiTemplateContext context) { - return titleCollection -> { - Map resourceIdToAccessType = mapResourceIdToTitleResult(titleCollection); - return relatedEntitiesLoader.loadAccessTypes(new ArrayList<>(resourceIdToAccessType.keySet()), - RecordType.RESOURCE, context) - .thenApply(accessTypeMap -> { - populateResourceAccessTypes(resourceIdToAccessType, accessTypeMap); - return titleCollection; - }); - }; - } - - private void populateResourceAccessTypes(Map resourceIdToTitle, - Map accessTypeMap) { - accessTypeMap.forEach((id, accessType) -> { - if (resourceIdToTitle.containsKey(id)) { - TitleResult titleResult = resourceIdToTitle.get(id); - titleResult.setResourceAccessType(accessType); - } - }); - } - - private Map mapResourceIdToTitleResult(TitleCollectionResult tc) { - return tc.getTitleResults().stream().collect(toMap(this::getResourceId, Function.identity())); - } - - private String getResourceId(TitleResult titleResult) { - CustomerResources resource = titleResult.getTitle().getCustomerResourcesList().getFirst(); - return resource.getVendorId() + "-" + resource.getPackageId() + "-" + resource.getTitleId(); - } - - private CompletableFuture getCustomProviderId(RmApiTemplateContext context) { - VendorIdCacheKey cacheKey = VendorIdCacheKey.builder() - .tenant(context.getRequestContext().getTenant()) - .rmapiConfiguration(context.getConfiguration()) - .build(); - var cachedId = vendorIdCache.getValue(cacheKey); - if (cachedId != null) { - return completedFuture(cachedId); - } else { - return context.getProvidersService().getVendorId() - .thenCompose(id -> { - vendorIdCache.putValue(cacheKey, id); - return completedFuture(id); - }); - } - } - - private CompletableFuture deleteTags(PackageId packageId, RmApiTemplateContext context) { - UUID credentialsId = toUUID(context.getCredentialsId()); - String tenant = context.getRequestContext().getTenant(); - - return packageRepository.delete(packageId, credentialsId, tenant) - .thenCompose(o -> tagRepository.deleteRecordTags(tenant, packageIdToString(packageId), RecordType.PACKAGE)) - .thenCompose(v -> completedFuture(null)); - } - - private CompletableFuture updateTags(Tags tags, DbPackage pkg, String tenant) { - if (tags == null) { - return completedFuture(null); - } else { - PackageId id = pkg.getId(); - return updateStoredPackage(tags, pkg, tenant) - .thenCompose( - o -> tagRepository.updateRecordTags(tenant, packageIdToString(id), RecordType.PACKAGE, tags.getTagList())) - .thenApply(updated -> null); - } - } - - private CompletableFuture updateStoredPackage(Tags tags, DbPackage pkg, String tenant) { - if (!tags.getTagList().isEmpty()) { - return packageRepository.save(pkg, tenant); - } - return packageRepository.delete(pkg.getId(), pkg.getCredentialsId(), tenant); - } - - private CompletableFuture processUpdateRequest(PackagePutRequest entity, PackageData originalPackage, - RmApiTemplateContext context) { - Boolean isEntityCustom = entity.getData().getAttributes().getIsCustom(); - validateIsCustomMatch(originalPackage.getIsCustom(), isEntityCustom); - - PackagePut packagePutBody; - if (BooleanUtils.isTrue(isEntityCustom)) { - validationService.validateCustomPackagePutRequest(entity); - packagePutBody = requestConvertionService.convertCustomPackagePutRequest(entity); - } else { - validationService.validateManagedPackagePutRequest(entity); - packagePutBody = requestConvertionService.convertManagedPackagePutRequest(entity); - } - return context.getPackagesService() - .updatePackage(originalPackage.getPackageId(), packagePutBody); - } - - private void validateIsCustomMatch(Boolean isOriginalCustom, Boolean isUpdatableCustom) { - if (!isOriginalCustom.equals(isUpdatableCustom)) { - throw new InputValidationException(PACKAGE_IS_CUSTOM_NOT_MATCHED, - String.format(PACKAGE_IS_CUSTOM_NOT_MATCHED_DETAILS, isOriginalCustom)); - } - } - - /** - * Delete local package, tags and access type mapping if package was deleted on update - * (normally this can only happen in case of custom package). - * - * @return future with initial result, or exceptionally completed future if deletion of tags failed - */ - private CompletableFuture handleDeletedPackage(CompletableFuture future, - PackageId packageId, RmApiTemplateContext context) { - CompletableFuture deleteFuture = new CompletableFuture<>(); - return future.whenComplete((packageById, e) -> { - if (e instanceof ResourceNotFoundException) { - deleteAssignedResources(packageId, context).thenAccept(o -> deleteFuture.complete(null)); - } else { - deleteFuture.complete(null); - } - }).thenCombine(deleteFuture, (o, v) -> future.join()); - } - - private CompletableFuture deleteAssignedResources(PackageId packageId, RmApiTemplateContext context) { - CompletableFuture deleteAccessMapping = updateRecordMapping(null, packageIdToString(packageId), context); - CompletableFuture deleteTags = deleteTags(packageId, context); - - return CompletableFuture.allOf(deleteAccessMapping, deleteTags); - } } diff --git a/src/main/java/org/folio/service/packages/CustomProviderIdService.java b/src/main/java/org/folio/service/packages/CustomProviderIdService.java new file mode 100644 index 00000000..d1f001da --- /dev/null +++ b/src/main/java/org/folio/service/packages/CustomProviderIdService.java @@ -0,0 +1,40 @@ +package org.folio.service.packages; + +import static java.util.concurrent.CompletableFuture.completedFuture; + +import java.util.concurrent.CompletableFuture; +import org.folio.cache.VertxCache; +import org.folio.config.cache.VendorIdCacheKey; +import org.folio.rest.util.template.RmApiTemplateContext; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +/** + * Resolves (and caches) the vendor id used to identify custom packages, hiding the + * {@link VertxCache} wiring from {@link PackageService}. + */ +@Service +public class CustomProviderIdService { + + private final VertxCache vendorIdCache; + + public CustomProviderIdService(@Qualifier("vendorIdCache") VertxCache vendorIdCache) { + this.vendorIdCache = vendorIdCache; + } + + public CompletableFuture getCustomProviderId(RmApiTemplateContext context) { + var cacheKey = VendorIdCacheKey.builder() + .tenant(context.getRequestContext().getTenant()) + .rmapiConfiguration(context.getConfiguration()) + .build(); + var cachedId = vendorIdCache.getValue(cacheKey); + if (cachedId != null) { + return completedFuture(cachedId); + } + return context.getProvidersService().getVendorId() + .thenCompose(id -> { + vendorIdCache.putValue(cacheKey, id); + return completedFuture(id); + }); + } +} diff --git a/src/main/java/org/folio/service/packages/PackageAccessTypeService.java b/src/main/java/org/folio/service/packages/PackageAccessTypeService.java new file mode 100644 index 00000000..3b30d183 --- /dev/null +++ b/src/main/java/org/folio/service/packages/PackageAccessTypeService.java @@ -0,0 +1,51 @@ +package org.folio.service.packages; + +import static java.util.concurrent.CompletableFuture.completedFuture; + +import java.util.concurrent.CompletableFuture; +import org.folio.repository.RecordType; +import org.folio.rest.jaxrs.model.AccessType; +import org.folio.rest.util.template.RmApiTemplateContext; +import org.folio.rmapi.result.PackageResult; +import org.folio.service.accesstypes.AccessTypeMappingsService; +import org.folio.service.accesstypes.AccessTypesService; +import org.springframework.stereotype.Service; + +/** + * Encapsulates access type lookup and record-mapping updates for packages, + * hiding {@link AccessTypesService} and {@link AccessTypeMappingsService} from {@link PackageService}. + */ +@Service +class PackageAccessTypeService { + + private final AccessTypesService accessTypesService; + private final AccessTypeMappingsService accessTypeMappingsService; + + PackageAccessTypeService(AccessTypesService accessTypesService, AccessTypeMappingsService accessTypeMappingsService) { + this.accessTypesService = accessTypesService; + this.accessTypeMappingsService = accessTypeMappingsService; + } + + CompletableFuture fetchAccessType(String accessTypeId, RmApiTemplateContext context) { + if (accessTypeId == null) { + return completedFuture(null); + } + return accessTypesService.findByCredentialsAndAccessTypeId(context.getCredentialsId(), accessTypeId, false, + context.getRequestContext().getHeaders()); + } + + CompletableFuture assignAccessType(AccessType accessType, PackageResult packageResult, + RmApiTemplateContext context) { + String recordId = packageResult.getPackageData().getFullPackageId(); + return updateRecordMapping(accessType, recordId, context) + .thenApply(v -> { + packageResult.setAccessType(accessType); + return packageResult; + }); + } + + CompletableFuture updateRecordMapping(AccessType accessType, String recordId, RmApiTemplateContext context) { + return accessTypeMappingsService.update(accessType, recordId, RecordType.PACKAGE, context.getCredentialsId(), + context.getRequestContext().getHeaders()); + } +} diff --git a/src/main/java/org/folio/service/packages/PackageService.java b/src/main/java/org/folio/service/packages/PackageService.java new file mode 100644 index 00000000..770c0b5f --- /dev/null +++ b/src/main/java/org/folio/service/packages/PackageService.java @@ -0,0 +1,257 @@ +package org.folio.service.packages; + +import static java.util.stream.Collectors.toMap; +import static org.folio.db.RowSetUtils.toUUID; +import static org.folio.rest.util.IdParser.packageIdToString; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +import org.folio.holdingsiq.model.CustomerResources; +import org.folio.holdingsiq.model.PackageData; +import org.folio.holdingsiq.model.PackageId; +import org.folio.holdingsiq.model.PackagePost; +import org.folio.holdingsiq.model.PackagePut; +import org.folio.holdingsiq.model.Packages; +import org.folio.holdingsiq.model.Titles; +import org.folio.holdingsiq.service.exception.ResourceNotFoundException; +import org.folio.properties.common.SearchProperties; +import org.folio.repository.RecordKey; +import org.folio.repository.RecordType; +import org.folio.repository.accesstypes.DbAccessType; +import org.folio.rest.converter.packages.PackageRequestConvertionService; +import org.folio.rest.exception.InputValidationException; +import org.folio.rest.jaxrs.model.PackagePostRequest; +import org.folio.rest.jaxrs.model.PackagePutRequest; +import org.folio.rest.jaxrs.model.PackageTagsDataAttributes; +import org.folio.rest.jaxrs.model.PackageTagsPutRequest; +import org.folio.rest.model.filter.PackageRecordFilter; +import org.folio.rest.model.filter.ResourceFilter; +import org.folio.rest.util.template.RmApiTemplateContext; +import org.folio.rest.validator.packages.PackageValidationService; +import org.folio.rmapi.result.PackageResult; +import org.folio.rmapi.result.TitleCollectionResult; +import org.folio.rmapi.result.TitleResult; +import org.folio.service.loader.RelatedEntitiesLoader; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PackageService { + + private static final String INVALID_PACKAGE_TITLE = "Package cannot be deleted"; + private static final String INVALID_PACKAGE_DETAILS = "Invalid package"; + private static final String PACKAGE_IS_CUSTOM_NOT_MATCHED = "Package isCustom not matched"; + private static final String PACKAGE_IS_CUSTOM_NOT_MATCHED_DETAILS = "Package isCustom: %s"; + + private final PackageRequestConvertionService requestConvertionService; + private final PackageValidationService validationService; + private final PackageAccessTypeService packageAccessTypeService; + private final PackageTagsService packageTagsService; + private final CustomProviderIdService customProviderIdService; + private final RelatedEntitiesLoader relatedEntitiesLoader; + private final Converter titleCollectionConverter; + private final SearchProperties searchProperties; + + public CompletableFuture retrievePackages(Integer providerId, PackageRecordFilter filter, + RmApiTemplateContext context) { + var packageFilter = filter.toClientFilter(searchProperties); + var pageable = filter.toPageable(); + return providerId == null + ? context.getPackagesService().retrievePackages(packageFilter, pageable) + : context.getPackagesService().retrievePackages(providerId, packageFilter, pageable); + } + + public CompletableFuture getCustomProviderIdAndRetrievePackages(PackageRecordFilter filter, + RmApiTemplateContext context) { + return customProviderIdService.getCustomProviderId(context) + .thenCompose(providerId -> retrievePackages(providerId, filter, context)); + } + + public Function> retrievePackageTitles(ResourceFilter filter) { + return context -> { + var pkgId = filter.parsePackageId(); + return context.getTitlesService() + .retrieveTitles(pkgId.providerIdPart(), pkgId.packageIdPart(), filter.createFilterQuery(), + searchProperties.titlesSearchType(), filter.resolveSort(), filter.getPage(), filter.getCount()) + .thenApply(titleCollectionConverter::convert) + .thenCompose(loadResourceTags(context)) + .thenCompose(loadResourceAccessTypes(context)); + }; + } + + public CompletableFuture createCustomPackage(PackagePostRequest entity, + RmApiTemplateContext context) { + validationService.validateCustomPackagePostRequest(entity); + PackagePost packagePost = requestConvertionService.convertCustomPackagePostRequest(entity); + String accessTypeId = entity.getData().getAttributes().getAccessTypeId(); + + if (accessTypeId == null) { + return postCustomPackage(packagePost, context); + } else { + return packageAccessTypeService.fetchAccessType(accessTypeId, context) + .thenCompose(accessType -> postCustomPackage(packagePost, context) + .thenCompose(packageResult -> packageAccessTypeService.assignAccessType(accessType, packageResult, context))); + } + } + + public CompletableFuture retrievePackageWithRelatedData(PackageId parsedPackageId, + List includedObjects, + RmApiTemplateContext context) { + return context.getPackagesService().retrievePackage(parsedPackageId, includedObjects) + .thenCompose(packageResult -> { + RecordKey recordKey = RecordKey.builder() + .recordId(packageIdToString(parsedPackageId)) + .recordType(RecordType.PACKAGE) + .build(); + return CompletableFuture.allOf( + relatedEntitiesLoader.loadAccessType(packageResult, recordKey, context), + relatedEntitiesLoader.loadTags(packageResult, recordKey, context)) + .thenApply(v -> packageResult); + }); + } + + public CompletableFuture updatePackage(PackageId parsedPackageId, PackagePutRequest entity, + RmApiTemplateContext context) { + var packageIdPart = parsedPackageId.packageIdPart(); + String accessTypeId = entity.getData().getAttributes().getAccessTypeId(); + return context.getPackagesService().retrievePackage(packageIdPart) + .thenCompose(packageData -> packageAccessTypeService.fetchAccessType(accessTypeId, context) + .thenCompose(accessType -> processUpdateRequest(entity, packageData, context) + .thenCompose(voidEntity -> { + CompletableFuture future = context.getPackagesService().retrievePackage(packageIdPart); + return handleDeletedPackage(future, parsedPackageId, context); + }) + .thenApply(packageById -> new PackageResult(packageById, null, null)) + .thenCompose(packageResult -> packageAccessTypeService.assignAccessType(accessType, packageResult, context)) + ) + ); + } + + public CompletableFuture deletePackage(PackageId parsedPackageId, RmApiTemplateContext context) { + var packageIdPart = parsedPackageId.packageIdPart(); + return context.getPackagesService().retrievePackage(packageIdPart) + .thenCompose(packageData -> { + if (BooleanUtils.isNotTrue(packageData.getIsCustom())) { + throw new InputValidationException(INVALID_PACKAGE_TITLE, INVALID_PACKAGE_DETAILS); + } + return context.getPackagesService().deletePackage(packageIdPart) + .thenCompose(v -> deleteAssignedResources(parsedPackageId, context)); + }); + } + + public CompletableFuture updateTagsForPackage(PackageTagsPutRequest entity, + UUID credentialsId, String packageId, + String tenant) { + validationService.validatePackageTagsPutRequest(entity); + PackageTagsDataAttributes attributes = entity.getData().getAttributes(); + + return packageTagsService.updatePackageTags(packageId, credentialsId, attributes, tenant) + .thenApply(v -> attributes); + } + + private CompletableFuture postCustomPackage(PackagePost packagePost, RmApiTemplateContext context) { + return customProviderIdService.getCustomProviderId(context) + .thenCompose(id -> context.getPackagesService().postPackage(packagePost, id)) + .thenApply(packageById -> new PackageResult(packageById, null, null)); + } + + private CompletableFuture processUpdateRequest(PackagePutRequest entity, PackageData originalPackage, + RmApiTemplateContext context) { + Boolean isEntityCustom = entity.getData().getAttributes().getIsCustom(); + validateIsCustomMatch(originalPackage.getIsCustom(), isEntityCustom); + + PackagePut packagePutBody; + if (BooleanUtils.isTrue(isEntityCustom)) { + validationService.validateCustomPackagePutRequest(entity); + packagePutBody = requestConvertionService.convertCustomPackagePutRequest(entity); + } else { + validationService.validateManagedPackagePutRequest(entity); + packagePutBody = requestConvertionService.convertManagedPackagePutRequest(entity); + } + return context.getPackagesService() + .updatePackage(originalPackage.getPackageId(), packagePutBody); + } + + private void validateIsCustomMatch(Boolean isOriginalCustom, Boolean isUpdatableCustom) { + if (!isOriginalCustom.equals(isUpdatableCustom)) { + throw new InputValidationException(PACKAGE_IS_CUSTOM_NOT_MATCHED, + String.format(PACKAGE_IS_CUSTOM_NOT_MATCHED_DETAILS, isOriginalCustom)); + } + } + + /** + * Delete local package, tags and access type mapping if package was deleted on update + * (normally this can only happen in case of custom package). + * + * @return future with initial result, or exceptionally completed future if deletion of tags failed + */ + private CompletableFuture handleDeletedPackage(CompletableFuture future, + PackageId packageId, RmApiTemplateContext context) { + CompletableFuture deleteFuture = new CompletableFuture<>(); + return future.whenComplete((packageById, e) -> { + if (e instanceof ResourceNotFoundException) { + deleteAssignedResources(packageId, context).thenAccept(o -> deleteFuture.complete(null)); + } else { + deleteFuture.complete(null); + } + }).thenCombine(deleteFuture, (o, v) -> future.join()); + } + + private CompletableFuture deleteAssignedResources(PackageId packageId, RmApiTemplateContext context) { + CompletableFuture deleteAccessMapping = packageAccessTypeService.updateRecordMapping(null, + packageIdToString(packageId), context); + CompletableFuture deleteTags = packageTagsService.deletePackageTags(packageId, + toUUID(context.getCredentialsId()), context.getRequestContext().getTenant()); + + return CompletableFuture.allOf(deleteAccessMapping, deleteTags); + } + + private Function> loadResourceTags( + RmApiTemplateContext context) { + return titleCollection -> { + Map resourceIdToTitle = mapResourceIdToTitleResult(titleCollection); + return packageTagsService.loadResourceTags(context.getRequestContext().getTenant(), resourceIdToTitle) + .thenApply(v -> titleCollection); + }; + } + + private Function> loadResourceAccessTypes( + RmApiTemplateContext context) { + return titleCollection -> { + Map resourceIdToAccessType = mapResourceIdToTitleResult(titleCollection); + return relatedEntitiesLoader.loadAccessTypes(new ArrayList<>(resourceIdToAccessType.keySet()), + RecordType.RESOURCE, context) + .thenApply(accessTypeMap -> { + populateResourceAccessTypes(resourceIdToAccessType, accessTypeMap); + return titleCollection; + }); + }; + } + + private void populateResourceAccessTypes(Map resourceIdToTitle, + Map accessTypeMap) { + accessTypeMap.forEach((id, accessType) -> { + if (resourceIdToTitle.containsKey(id)) { + TitleResult titleResult = resourceIdToTitle.get(id); + titleResult.setResourceAccessType(accessType); + } + }); + } + + private Map mapResourceIdToTitleResult(TitleCollectionResult tc) { + return tc.getTitleResults().stream().collect(toMap(this::getResourceId, Function.identity())); + } + + private String getResourceId(TitleResult titleResult) { + CustomerResources resource = titleResult.getTitle().getCustomerResourcesList().getFirst(); + return resource.getVendorId() + "-" + resource.getPackageId() + "-" + resource.getTitleId(); + } +} diff --git a/src/main/java/org/folio/service/packages/PackageTagsService.java b/src/main/java/org/folio/service/packages/PackageTagsService.java new file mode 100644 index 00000000..1a485c26 --- /dev/null +++ b/src/main/java/org/folio/service/packages/PackageTagsService.java @@ -0,0 +1,83 @@ +package org.folio.service.packages; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.folio.rest.util.IdParser.packageIdToString; +import static org.folio.rest.util.IdParser.parsePackageId; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.folio.holdingsiq.model.PackageId; +import org.folio.repository.RecordType; +import org.folio.repository.packages.DbPackage; +import org.folio.repository.packages.PackageRepository; +import org.folio.repository.tag.DbTag; +import org.folio.repository.tag.TagRepository; +import org.folio.rest.converter.common.ConverterConsts; +import org.folio.rest.jaxrs.model.PackageTagsDataAttributes; +import org.folio.rest.jaxrs.model.Tags; +import org.folio.rmapi.result.TitleResult; +import org.springframework.stereotype.Service; + +/** + * Encapsulates local storage of packages and their tags, hiding {@link PackageRepository} + * and {@link TagRepository} from {@link PackageService}. + */ +@Service +class PackageTagsService { + + private final PackageRepository packageRepository; + private final TagRepository tagRepository; + + PackageTagsService(PackageRepository packageRepository, TagRepository tagRepository) { + this.packageRepository = packageRepository; + this.tagRepository = tagRepository; + } + + CompletableFuture updatePackageTags(String packageId, UUID credentialsId, + PackageTagsDataAttributes attributes, String tenant) { + Tags tags = attributes.getTags(); + if (tags == null) { + return completedFuture(null); + } + DbPackage dbPackage = createDbPackage(packageId, credentialsId, attributes); + PackageId id = dbPackage.getId(); + return updateStoredPackage(tags, dbPackage, tenant) + .thenCompose( + o -> tagRepository.updateRecordTags(tenant, packageIdToString(id), RecordType.PACKAGE, tags.getTagList())) + .thenApply(updated -> null); + } + + CompletableFuture deletePackageTags(PackageId packageId, UUID credentialsId, String tenant) { + return packageRepository.delete(packageId, credentialsId, tenant) + .thenCompose(o -> tagRepository.deleteRecordTags(tenant, packageIdToString(packageId), RecordType.PACKAGE)) + .thenCompose(v -> completedFuture(null)); + } + + CompletableFuture loadResourceTags(String tenant, Map resourceIdToTitle) { + return tagRepository.findPerRecord(tenant, new ArrayList<>(resourceIdToTitle.keySet()), RecordType.RESOURCE) + .thenAccept(tagMap -> populateResourceTags(resourceIdToTitle, tagMap)); + } + + private void populateResourceTags(Map resourceIdToTitle, Map> tagMap) { + tagMap.forEach((id, tags) -> resourceIdToTitle.get(id).setResourceTagList(tags)); + } + + private CompletableFuture updateStoredPackage(Tags tags, DbPackage pkg, String tenant) { + if (!tags.getTagList().isEmpty()) { + return packageRepository.save(pkg, tenant); + } + return packageRepository.delete(pkg.getId(), pkg.getCredentialsId(), tenant); + } + + private DbPackage createDbPackage(String packageId, UUID credentialsId, PackageTagsDataAttributes attributes) { + return DbPackage.builder() + .id(parsePackageId(packageId)) + .credentialsId(credentialsId) + .name(attributes.getName()) + .contentType(ConverterConsts.CONTENT_TYPES.inverseBidiMap().get(attributes.getContentType())) + .build(); + } +} diff --git a/src/main/java/org/folio/service/sanitizer/HtmlSanitizer.java b/src/main/java/org/folio/service/sanitizer/HtmlSanitizer.java new file mode 100644 index 00000000..b8909484 --- /dev/null +++ b/src/main/java/org/folio/service/sanitizer/HtmlSanitizer.java @@ -0,0 +1,40 @@ +package org.folio.service.sanitizer; + +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; +import org.springframework.stereotype.Component; + +/** + * Strips HTML content down to a fixed set of allowed tags and attributes using {@link Jsoup}. + * + *

The allowed set is hardcoded rather than externally configurable: {@code p}, {@code strong}, {@code em}, + * {@code a}, {@code u}, {@code ol}, {@code ul}, {@code li}, {@code h1}, {@code h2}, {@code h3} and {@code br} tags, + * plus {@code class}/{@code style} attributes on any allowed tag and {@code href}/{@code rel}/{@code target} on + * {@code a}. Any other tag or attribute is removed, though the text content of a stripped tag is kept (with the + * exception of tags like {@code script} whose content is data, not text, and is discarded).

+ */ +@Component +public class HtmlSanitizer implements Sanitizer { + + private static final Safelist SAFELIST = new Safelist() + .addTags("p", "strong", "em", "a", "u", "ol", "ul", "li", "h1", "h2", "h3", "br") + .addAttributes(":all", "class", "style") + .addAttributes("a", "href", "rel", "target"); + + private static final Document.OutputSettings OUTPUT_SETTINGS = new Document.OutputSettings().prettyPrint(false); + + /** + * Sanitizes the given HTML content, removing any tag or attribute not on the allowed list. + * + * @param target the HTML content to sanitize; a blank or {@code null} value is returned unchanged. + * @return the sanitized content, with output pretty-printing disabled so it is not reformatted. + */ + @Override + public String sanitize(String target) { + return StringUtils.isNotBlank(target) + ? Jsoup.clean(target, "", SAFELIST, OUTPUT_SETTINGS) + : target; + } +} diff --git a/src/main/java/org/folio/service/sanitizer/Sanitizer.java b/src/main/java/org/folio/service/sanitizer/Sanitizer.java new file mode 100644 index 00000000..ad11cdbc --- /dev/null +++ b/src/main/java/org/folio/service/sanitizer/Sanitizer.java @@ -0,0 +1,6 @@ +package org.folio.service.sanitizer; + +public interface Sanitizer { + + T sanitize(T target); +} diff --git a/src/test/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverterTest.java b/src/test/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverterTest.java index 6834ad83..0ca2a5b2 100644 --- a/src/test/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverterTest.java +++ b/src/test/java/org/folio/rest/converter/packages/CustomPackagePostRequestConverterTest.java @@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import org.folio.rest.jaxrs.model.ContentType; @@ -15,11 +17,21 @@ import org.folio.rest.jaxrs.model.PackagePostDataAttributes; import org.folio.rest.jaxrs.model.PackagePostRequest; import org.folio.rest.jaxrs.model.ProxyDto; +import org.folio.service.sanitizer.Sanitizer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +@ExtendWith(MockitoExtension.class) class CustomPackagePostRequestConverterTest { - private final CustomPackagePostRequestConverter converter = new CustomPackagePostRequestConverter(); + @Mock + private Sanitizer htmlSanitizer; + + @InjectMocks + private CustomPackagePostRequestConverter converter; @Test void shouldMapPackageName() { @@ -30,11 +42,15 @@ void shouldMapPackageName() { } @Test - void shouldMapCustomDescription() { + void shouldSanitizeCustomDescription() { + when(htmlSanitizer.sanitize("Some description")).thenReturn("sanitized description"); + var result = converter.convert(buildRequest(new PackagePostDataAttributes() .withContentType(ContentType.UNKNOWN) .withCustomDescription("Some description"))); - assertEquals("Some description", result.getCustomDescription()); + + assertEquals("sanitized description", result.getCustomDescription()); + verify(htmlSanitizer).sanitize("Some description"); } @Test diff --git a/src/test/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverterTest.java b/src/test/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverterTest.java index 91495872..2d276b5d 100644 --- a/src/test/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverterTest.java +++ b/src/test/java/org/folio/rest/converter/packages/CustomPackagePutRequestConverterTest.java @@ -6,17 +6,29 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import org.folio.rest.jaxrs.model.ContentType; import org.folio.rest.jaxrs.model.Coverage; import org.folio.rest.jaxrs.model.PackagePutDataAttributes; import org.folio.rest.jaxrs.model.PackageVisibility; +import org.folio.service.sanitizer.Sanitizer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +@ExtendWith(MockitoExtension.class) class CustomPackagePutRequestConverterTest { - private final CustomPackagePutRequestConverter converter = new CustomPackagePutRequestConverter(); + @Mock + private Sanitizer htmlSanitizer; + + @InjectMocks + private CustomPackagePutRequestConverter converter; @Test void shouldCreateRequestToChangeCustomPackageName() { @@ -48,6 +60,18 @@ void shouldCreateRequestToChangeCustomPackageCoverageDatesToEmpty() { assertEquals("", packagePut.getCustomCoverage().getEndCoverage()); } + @Test + void shouldSanitizeCustomDescription() { + when(htmlSanitizer.sanitize("Some description")).thenReturn("sanitized description"); + + var packagePut = converter.convert(getPackagePutRequest( + new PackagePutDataAttributes() + .withCustomDescription("Some description"))); + + assertEquals("sanitized description", packagePut.getCustomDescription()); + verify(htmlSanitizer).sanitize("Some description"); + } + @Test void shouldCreateRequestToChangeCustomPackageContentType() { var packagePut = converter.convert(getPackagePutRequest( diff --git a/src/test/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverterTest.java b/src/test/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverterTest.java index 60982fc3..61b54b48 100644 --- a/src/test/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverterTest.java +++ b/src/test/java/org/folio/rest/converter/packages/ManagedPackagePutRequestConverterTest.java @@ -6,16 +6,28 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import org.folio.rest.jaxrs.model.Coverage; import org.folio.rest.jaxrs.model.PackagePutDataAttributes; import org.folio.rest.jaxrs.model.PackageVisibility; +import org.folio.service.sanitizer.Sanitizer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +@ExtendWith(MockitoExtension.class) class ManagedPackagePutRequestConverterTest { - private final ManagedPackagePutRequestConverter converter = new ManagedPackagePutRequestConverter(); + @Mock + private Sanitizer htmlSanitizer; + + @InjectMocks + private ManagedPackagePutRequestConverter converter; @Test void shouldCreateRequestToSelectPackage() { @@ -63,6 +75,18 @@ void shouldCreateRequestToAllowKbAddTitlesToPackage() { assertTrue(packagePut.getAllowEbscoToAddTitles()); } + @Test + void shouldSanitizeCustomDescription() { + when(htmlSanitizer.sanitize("Some description")).thenReturn("sanitized description"); + + var packagePut = converter.convert(getPackagePutRequest( + new PackagePutDataAttributes() + .withCustomDescription("Some description"))); + + assertEquals("sanitized description", packagePut.getCustomDescription()); + verify(htmlSanitizer).sanitize("Some description"); + } + @Test void shouldCreateRequestToAddCustomCoverage() { var packagePut = converter.convert(getPackagePutRequest( diff --git a/src/test/java/org/folio/rest/impl/EholdingsPackagesImplTest.java b/src/test/java/org/folio/rest/impl/EholdingsPackagesImplTest.java new file mode 100644 index 00000000..bf965faa --- /dev/null +++ b/src/test/java/org/folio/rest/impl/EholdingsPackagesImplTest.java @@ -0,0 +1,316 @@ +package org.folio.rest.impl; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Handler; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Response; +import org.folio.HttpStatus; +import org.folio.holdingsiq.model.PackageId; +import org.folio.holdingsiq.service.exception.ResourceNotFoundException; +import org.folio.okapi.common.XOkapiHeaders; +import org.folio.rest.exception.InputValidationException; +import org.folio.rest.jaxrs.model.KbCredentials; +import org.folio.rest.jaxrs.model.PackagePostBulkFetchRequest; +import org.folio.rest.jaxrs.model.PackagePostData; +import org.folio.rest.jaxrs.model.PackagePostDataAttributes; +import org.folio.rest.jaxrs.model.PackagePostRequest; +import org.folio.rest.jaxrs.model.PackagePutData; +import org.folio.rest.jaxrs.model.PackagePutDataAttributes; +import org.folio.rest.jaxrs.model.PackagePutRequest; +import org.folio.rest.jaxrs.model.PackageTagsDataAttributes; +import org.folio.rest.jaxrs.model.PackageTagsPutData; +import org.folio.rest.jaxrs.model.PackageTagsPutRequest; +import org.folio.rest.model.filter.PackageRecordFilter; +import org.folio.rest.model.filter.ResourceFilter; +import org.folio.rest.util.template.RmApiTemplate; +import org.folio.rest.util.template.RmApiTemplateContext; +import org.folio.rest.util.template.RmApiTemplateFactory; +import org.folio.rmapi.PackageServiceImpl; +import org.folio.service.kbcredentials.UserKbCredentialsService; +import org.folio.service.loader.FilteredEntitiesLoader; +import org.folio.service.packages.PackageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.objenesis.ObjenesisStd; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class EholdingsPackagesImplTest { + + private static final Map OKAPI_HEADERS = Map.of( + XOkapiHeaders.TENANT, "fs", + XOkapiHeaders.URL, "http://localhost:8080"); + + @Mock + private PackageService packageService; + @Mock + private RmApiTemplateFactory templateFactory; + @Mock + private FilteredEntitiesLoader filteredEntitiesLoader; + @Mock + private UserKbCredentialsService userKbCredentialsService; + @Mock + private RmApiTemplate template; + @Mock + private Handler> asyncResultHandler; + @Mock + private PackageServiceImpl packagesRmApiService; + @Captor + private ArgumentCaptor> responseCaptor; + + private EholdingsPackagesImpl impl; + private RmApiTemplateContext context; + + @BeforeEach + void setUp() { + impl = new ObjenesisStd().newInstance(EholdingsPackagesImpl.class); + ReflectionTestUtils.setField(impl, "packageService", packageService); + ReflectionTestUtils.setField(impl, "templateFactory", templateFactory); + ReflectionTestUtils.setField(impl, "filteredEntitiesLoader", filteredEntitiesLoader); + ReflectionTestUtils.setField(impl, "userKbCredentialsService", userKbCredentialsService); + + lenient().when(template.requestAction(any())).thenReturn(template); + lenient().when(template.addErrorMapper(any(), any())).thenReturn(template); + lenient().when(templateFactory.createTemplate(anyMap(), ArgumentMatchers.>>any())) + .thenReturn(template); + + context = RmApiTemplateContext.builder() + .credentialsId(UUID.randomUUID().toString()) + .packagesService(packagesRmApiService) + .build(); + } + + @SuppressWarnings("unchecked") + private Function> captureRequestAction() { + var captor = ArgumentCaptor.forClass(Function.class); + verify(template).requestAction(captor.capture()); + return captor.getValue(); + } + + @Test + void shouldFetchPackagesByTagFilterWhenTagsFilterIsGiven() { + var filter = PackageRecordFilter.builder().filterTags(List.of("tag1")).sort("relevance") + .page(1).count(25).build(); + when(filteredEntitiesLoader.fetchPackagesByTagFilter(any(), any())).thenReturn(completedFuture(null)); + + invokeGetEholdingsPackages(filter); + + captureRequestAction().apply(context); + verify(filteredEntitiesLoader).fetchPackagesByTagFilter(any(), eq(context)); + verify(packageService, never()).retrievePackages(any(), any(), any()); + verify(template).executeWithResult(org.folio.rest.jaxrs.model.PackageCollection.class); + } + + @Test + void shouldFetchPackagesByAccessTypeFilterWhenAccessTypeFilterIsGiven() { + var filter = PackageRecordFilter.builder().filterAccessType(List.of("at1")).sort("relevance") + .page(1).count(25).build(); + when(filteredEntitiesLoader.fetchPackagesByAccessTypeFilter(any(), any())).thenReturn(completedFuture(null)); + + invokeGetEholdingsPackages(filter); + + captureRequestAction().apply(context); + verify(filteredEntitiesLoader).fetchPackagesByAccessTypeFilter(any(), eq(context)); + } + + @Test + void shouldResolveCustomProviderIdWhenFilterCustomIsTrue() { + var filter = PackageRecordFilter.builder().filterCustom("true").sort("relevance").page(1).count(25).build(); + when(packageService.getCustomProviderIdAndRetrievePackages(filter, context)).thenReturn(completedFuture(null)); + + invokeGetEholdingsPackages(filter); + + captureRequestAction().apply(context); + verify(packageService).getCustomProviderIdAndRetrievePackages(filter, context); + verify(packageService, never()).retrievePackages(any(), any(), any()); + } + + @Test + void shouldRetrieveAllPackagesWhenFilterCustomIsNotSet() { + var filter = PackageRecordFilter.builder().sort("relevance").page(1).count(25).build(); + when(packageService.retrievePackages(null, filter, context)).thenReturn(completedFuture(null)); + + invokeGetEholdingsPackages(filter); + + captureRequestAction().apply(context); + verify(packageService).retrievePackages(null, filter, context); + } + + private void invokeGetEholdingsPackages(PackageRecordFilter filter) { + impl.getEholdingsPackages(filter.getFilterCustom(), filter.getQuery(), filter.getQueryField(), + filter.getQueryType(), filter.isHighlight(), filter.getFilterSelected(), filter.getFilterType(), + filter.getFilterVisibility(), filter.getFilterFreeAccess(), filter.getFilterTags(), + filter.getFilterAccessType(), filter.getSort(), filter.getPage(), filter.getCount(), OKAPI_HEADERS, + asyncResultHandler, null); + } + + @Test + void shouldCreateCustomPackage() { + var entity = new PackagePostRequest() + .withData(new PackagePostData().withAttributes(new PackagePostDataAttributes())); + when(packageService.createCustomPackage(entity, context)).thenReturn(completedFuture(null)); + + impl.postEholdingsPackages("application/json", entity, OKAPI_HEADERS, asyncResultHandler, null); + + captureRequestAction().apply(context); + verify(packageService).createCustomPackage(entity, context); + verify(template).addErrorMapper(eq(NotFoundException.class), any()); + verify(template).executeWithResult(org.folio.rest.jaxrs.model.Package.class); + } + + @Test + void shouldRetrievePackageWithRelatedData() { + when(packageService.retrievePackageWithRelatedData(any(), any(), any())).thenReturn(completedFuture(null)); + + impl.getEholdingsPackagesByPackageId("19-3964", "provider,resources", OKAPI_HEADERS, asyncResultHandler, null); + + captureRequestAction().apply(context); + verify(packageService).retrievePackageWithRelatedData(new PackageId(19, 3964), + List.of("provider", "resources"), context); + verify(template).executeWithResult(org.folio.rest.jaxrs.model.Package.class); + } + + @Test + void shouldUpdatePackage() { + var entity = new PackagePutRequest() + .withData(new PackagePutData().withAttributes(new PackagePutDataAttributes())); + when(packageService.updatePackage(any(), any(), any())).thenReturn(completedFuture(null)); + + impl.putEholdingsPackagesByPackageId("19-3964", "application/json", entity, OKAPI_HEADERS, asyncResultHandler, + null); + + captureRequestAction().apply(context); + verify(packageService).updatePackage(new PackageId(19, 3964), entity, context); + verify(template).addErrorMapper(eq(NotFoundException.class), any()); + verify(template).addErrorMapper(eq(InputValidationException.class), any()); + verify(template).executeWithResult(org.folio.rest.jaxrs.model.Package.class); + } + + @Test + void shouldDeletePackage() { + when(packageService.deletePackage(any(), any())).thenReturn(completedFuture(null)); + + impl.deleteEholdingsPackagesByPackageId("19-3964", OKAPI_HEADERS, asyncResultHandler, null); + + captureRequestAction().apply(context); + verify(packageService).deletePackage(new PackageId(19, 3964), context); + verify(template).execute(); + verify(template, never()).executeWithResult(any()); + } + + @Test + void shouldFetchResourcesByTagFilterWhenTagsFilterIsGiven() { + var filter = ResourceFilter.builder().packageId("19-3964").filterTags(List.of("tag1")) + .page(1).count(25).build(); + when(filteredEntitiesLoader.fetchResourcesByTagFilter(any(), any())).thenReturn(completedFuture(null)); + + invokeGetResources(filter); + + captureRequestAction().apply(context); + verify(filteredEntitiesLoader).fetchResourcesByTagFilter(any(), eq(context)); + verify(template).addErrorMapper(eq(ResourceNotFoundException.class), any()); + verify(template).executeWithResult(org.folio.rest.jaxrs.model.ResourceCollection.class); + } + + @Test + void shouldFetchResourcesByAccessTypeFilterWhenAccessTypeFilterIsGiven() { + var filter = ResourceFilter.builder().packageId("19-3964").filterAccessType(List.of("at1")) + .page(1).count(25).build(); + when(filteredEntitiesLoader.fetchResourcesByAccessTypeFilter(any(), any())).thenReturn(completedFuture(null)); + + invokeGetResources(filter); + + captureRequestAction().apply(context); + verify(filteredEntitiesLoader).fetchResourcesByAccessTypeFilter(any(), eq(context)); + } + + @Test + void shouldRetrievePackageTitlesWhenNoTagOrAccessTypeFilterIsGiven() { + var filter = ResourceFilter.builder().packageId("19-3964").filterName("test").sort("relevance") + .page(1).count(25).build(); + Function> stubFunction = ctx -> completedFuture(null); + when(packageService.retrievePackageTitles(filter)).thenReturn(stubFunction); + + invokeGetResources(filter); + + verify(template).requestAction(same(stubFunction)); + } + + private void invokeGetResources(ResourceFilter filter) { + impl.getEholdingsPackagesResourcesByPackageId(filter.getPackageId(), filter.getFilterTags(), + filter.getFilterAccessType(), filter.getFilterSelected(), filter.getFilterType(), filter.getFilterName(), + filter.getFilterIsxn(), filter.getFilterSubject(), filter.getFilterPublisher(), filter.getSort(), + filter.getPage(), filter.getCount(), OKAPI_HEADERS, asyncResultHandler, null); + } + + @Test + void shouldUpdateTagsForPackage() { + var credentialsId = UUID.randomUUID(); + var attributes = new PackageTagsDataAttributes().withName("name"); + var entity = new PackageTagsPutRequest().withData(new PackageTagsPutData().withAttributes(attributes)); + when(userKbCredentialsService.findByUser(OKAPI_HEADERS)) + .thenReturn(completedFuture(new KbCredentials().withId(credentialsId.toString()))); + when(packageService.updateTagsForPackage(entity, credentialsId, "19-3964", "fs")) + .thenReturn(completedFuture(attributes)); + + impl.putEholdingsPackagesTagsByPackageId("19-3964", "application/json", entity, OKAPI_HEADERS, + asyncResultHandler, null); + + verify(asyncResultHandler).handle(responseCaptor.capture()); + var response = responseCaptor.getValue().result(); + assertEquals(HttpStatus.SC_OK, response.getStatus()); + verify(packageService).updateTagsForPackage(entity, credentialsId, "19-3964", "fs"); + } + + @Test + void shouldReturn422WhenTagsUpdateFails() { + var credentialsId = UUID.randomUUID(); + var attributes = new PackageTagsDataAttributes().withName("name"); + var entity = new PackageTagsPutRequest().withData(new PackageTagsPutData().withAttributes(attributes)); + when(userKbCredentialsService.findByUser(OKAPI_HEADERS)) + .thenReturn(completedFuture(new KbCredentials().withId(credentialsId.toString()))); + when(packageService.updateTagsForPackage(any(), any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new InputValidationException("invalid", "invalid tags"))); + + impl.putEholdingsPackagesTagsByPackageId("19-3964", "application/json", entity, OKAPI_HEADERS, + asyncResultHandler, null); + + verify(asyncResultHandler).handle(responseCaptor.capture()); + var response = responseCaptor.getValue().result(); + assertEquals(HttpStatus.SC_UNPROCESSABLE_CONTENT, response.getStatus()); + } + + @Test + void shouldRetrievePackagesBulk() { + var entity = new PackagePostBulkFetchRequest().withPackages(java.util.Set.of("19-3964")); + when(packagesRmApiService.retrievePackagesBulk(entity.getPackages())).thenReturn(completedFuture(null)); + + impl.postEholdingsPackagesBulkFetch("application/json", entity, OKAPI_HEADERS, asyncResultHandler, null); + + captureRequestAction().apply(context); + verify(packagesRmApiService).retrievePackagesBulk(entity.getPackages()); + verify(template).executeWithResult(org.folio.rest.jaxrs.model.PackageBulkFetchCollection.class); + } +} diff --git a/src/test/java/org/folio/service/packages/CustomProviderIdServiceTest.java b/src/test/java/org/folio/service/packages/CustomProviderIdServiceTest.java new file mode 100644 index 00000000..ad82cb7e --- /dev/null +++ b/src/test/java/org/folio/service/packages/CustomProviderIdServiceTest.java @@ -0,0 +1,88 @@ +package org.folio.service.packages; + +import static org.folio.util.TestUtil.result; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.vertx.core.Vertx; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.folio.cache.VertxCache; +import org.folio.config.cache.VendorIdCacheKey; +import org.folio.holdingsiq.model.Configuration; +import org.folio.holdingsiq.model.RequestContext; +import org.folio.okapi.common.XOkapiHeaders; +import org.folio.rest.util.template.RmApiTemplateContext; +import org.folio.rmapi.ProvidersServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CustomProviderIdServiceTest { + + @Mock + private ProvidersServiceImpl providersService; + + private CustomProviderIdService customProviderIdService; + private RmApiTemplateContext context; + + @BeforeEach + void setUp() { + var vendorIdCache = new VertxCache(Vertx.vertx(), 60, "vendorIdCacheTest"); + customProviderIdService = new CustomProviderIdService(vendorIdCache); + + var requestContext = new RequestContext(Map.of( + XOkapiHeaders.TENANT, "fs", + XOkapiHeaders.URL, "http://localhost:8080")); + var configuration = Configuration.builder().customerId("customerId").apiKey("apiKey").build(); + + context = RmApiTemplateContext.builder() + .requestContext(requestContext) + .configuration(configuration) + .providersService(providersService) + .build(); + } + + @Test + void shouldFetchVendorIdWhenNotCached() { + when(providersService.getVendorId()).thenReturn(CompletableFuture.completedFuture(111)); + + var result = result(customProviderIdService.getCustomProviderId(context)); + + assertEquals(111, result); + verify(providersService).getVendorId(); + } + + @Test + void shouldReturnCachedVendorIdWithoutCallingRmApiAgain() { + when(providersService.getVendorId()).thenReturn(CompletableFuture.completedFuture(111)); + + var firstResult = result(customProviderIdService.getCustomProviderId(context)); + var secondResult = result(customProviderIdService.getCustomProviderId(context)); + + assertEquals(111, firstResult); + assertEquals(111, secondResult); + verify(providersService, times(1)).getVendorId(); + } + + @Test + void shouldNotShareCacheAcrossDifferentTenants() { + when(providersService.getVendorId()).thenReturn(CompletableFuture.completedFuture(111)); + + result(customProviderIdService.getCustomProviderId(context)); + + var otherTenantContext = context.toBuilder() + .requestContext(new RequestContext(Map.of( + XOkapiHeaders.TENANT, "other", + XOkapiHeaders.URL, "http://localhost:8080"))) + .build(); + result(customProviderIdService.getCustomProviderId(otherTenantContext)); + + verify(providersService, times(2)).getVendorId(); + } +} diff --git a/src/test/java/org/folio/service/packages/PackageAccessTypeServiceTest.java b/src/test/java/org/folio/service/packages/PackageAccessTypeServiceTest.java new file mode 100644 index 00000000..a44f0ce5 --- /dev/null +++ b/src/test/java/org/folio/service/packages/PackageAccessTypeServiceTest.java @@ -0,0 +1,99 @@ +package org.folio.service.packages; + +import static org.folio.util.TestUtil.result; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.folio.holdingsiq.model.PackageData; +import org.folio.holdingsiq.model.RequestContext; +import org.folio.okapi.common.XOkapiHeaders; +import org.folio.repository.RecordType; +import org.folio.rest.jaxrs.model.AccessType; +import org.folio.rest.util.template.RmApiTemplateContext; +import org.folio.rmapi.result.PackageResult; +import org.folio.service.accesstypes.AccessTypeMappingsService; +import org.folio.service.accesstypes.AccessTypesService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PackageAccessTypeServiceTest { + + private static final String CREDENTIALS_ID = "credentials-id"; + private static final String ACCESS_TYPE_ID = "access-type-id"; + + @Mock + private AccessTypesService accessTypesService; + @Mock + private AccessTypeMappingsService accessTypeMappingsService; + @InjectMocks + private PackageAccessTypeService packageAccessTypeService; + private RmApiTemplateContext context; + + @BeforeEach + void setUp() { + var requestContext = new RequestContext(Map.of( + XOkapiHeaders.TENANT, "fs", + XOkapiHeaders.URL, "http://localhost:8080")); + context = RmApiTemplateContext.builder() + .requestContext(requestContext) + .credentialsId(CREDENTIALS_ID) + .build(); + } + + @Test + void shouldReturnNullWhenAccessTypeIdIsNull() { + var result = result(packageAccessTypeService.fetchAccessType(null, context)); + + assertNull(result); + verifyNoInteractions(accessTypesService); + } + + @Test + void shouldFetchAccessTypeByCredentialsAndAccessTypeId() { + var accessType = new AccessType(); + when(accessTypesService.findByCredentialsAndAccessTypeId(CREDENTIALS_ID, ACCESS_TYPE_ID, false, + context.getRequestContext().getHeaders())).thenReturn(CompletableFuture.completedFuture(accessType)); + + var result = result(packageAccessTypeService.fetchAccessType(ACCESS_TYPE_ID, context)); + + assertSame(accessType, result); + } + + @Test + void shouldAssignAccessTypeToPackageResultAndUpdateMapping() { + when(accessTypeMappingsService.update(any(), any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + var accessType = new AccessType(); + var packageData = PackageData.builder().vendorId(19).packageId(3964).build(); + var packageResult = new PackageResult(packageData); + + var result = result(packageAccessTypeService.assignAccessType(accessType, packageResult, context)); + + assertSame(packageResult, result); + assertSame(accessType, result.getAccessType()); + verify(accessTypeMappingsService).update(accessType, "19-3964", RecordType.PACKAGE, CREDENTIALS_ID, + context.getRequestContext().getHeaders()); + } + + @Test + void shouldUpdateRecordMappingForGivenRecordId() { + when(accessTypeMappingsService.update(any(), any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + + result(packageAccessTypeService.updateRecordMapping(null, "19-3964", context)); + + verify(accessTypeMappingsService).update(null, "19-3964", RecordType.PACKAGE, CREDENTIALS_ID, + context.getRequestContext().getHeaders()); + } +} diff --git a/src/test/java/org/folio/service/packages/PackageServiceTest.java b/src/test/java/org/folio/service/packages/PackageServiceTest.java new file mode 100644 index 00000000..062444ff --- /dev/null +++ b/src/test/java/org/folio/service/packages/PackageServiceTest.java @@ -0,0 +1,315 @@ +package org.folio.service.packages; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.folio.util.TestUtil.result; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.folio.holdingsiq.model.Configuration; +import org.folio.holdingsiq.model.PackageData; +import org.folio.holdingsiq.model.PackageId; +import org.folio.holdingsiq.model.PackagePost; +import org.folio.holdingsiq.model.PackagePut; +import org.folio.holdingsiq.model.Packages; +import org.folio.holdingsiq.model.RequestContext; +import org.folio.holdingsiq.model.Titles; +import org.folio.holdingsiq.service.exception.ResourceNotFoundException; +import org.folio.okapi.common.XOkapiHeaders; +import org.folio.properties.common.SearchProperties; +import org.folio.repository.RecordKey; +import org.folio.repository.RecordType; +import org.folio.rest.converter.packages.PackageRequestConvertionService; +import org.folio.rest.exception.InputValidationException; +import org.folio.rest.jaxrs.model.AccessType; +import org.folio.rest.jaxrs.model.PackagePostData; +import org.folio.rest.jaxrs.model.PackagePostDataAttributes; +import org.folio.rest.jaxrs.model.PackagePostRequest; +import org.folio.rest.jaxrs.model.PackagePutData; +import org.folio.rest.jaxrs.model.PackagePutDataAttributes; +import org.folio.rest.jaxrs.model.PackagePutRequest; +import org.folio.rest.jaxrs.model.PackageTagsDataAttributes; +import org.folio.rest.jaxrs.model.PackageTagsPutData; +import org.folio.rest.jaxrs.model.PackageTagsPutRequest; +import org.folio.rest.model.filter.PackageRecordFilter; +import org.folio.rest.util.template.RmApiTemplateContext; +import org.folio.rest.validator.packages.PackageValidationService; +import org.folio.rmapi.PackageServiceImpl; +import org.folio.rmapi.ProvidersServiceImpl; +import org.folio.rmapi.TitlesServiceImpl; +import org.folio.rmapi.result.PackageResult; +import org.folio.rmapi.result.TitleCollectionResult; +import org.folio.service.loader.RelatedEntitiesLoader; +import org.folio.util.TestFutureFailedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.convert.converter.Converter; + +@ExtendWith(MockitoExtension.class) +class PackageServiceTest { + + private static final String CREDENTIALS_ID = UUID.randomUUID().toString(); + private static final String TENANT = "fs"; + + @Mock + private PackageRequestConvertionService requestConvertionService; + @Mock + private PackageValidationService validationService; + @Mock + private PackageAccessTypeService packageAccessTypeService; + @Mock + private PackageTagsService packageTagsService; + @Mock + private CustomProviderIdService customProviderIdService; + @Mock + private RelatedEntitiesLoader relatedEntitiesLoader; + @Mock + private Converter titleCollectionConverter; + @Mock + private PackageServiceImpl packagesRmApiService; + @Mock + private ProvidersServiceImpl providersRmApiService; + @Mock + private TitlesServiceImpl titlesRmApiService; + + private PackageService packageService; + private RmApiTemplateContext context; + + @BeforeEach + void setUp() { + var searchProperties = new SearchProperties("any", "any", "b"); + packageService = new PackageService(requestConvertionService, validationService, packageAccessTypeService, + packageTagsService, customProviderIdService, relatedEntitiesLoader, titleCollectionConverter, searchProperties); + + var requestContext = new RequestContext(Map.of( + XOkapiHeaders.TENANT, TENANT, + XOkapiHeaders.URL, "http://localhost:8080")); + context = RmApiTemplateContext.builder() + .requestContext(requestContext) + .configuration(Configuration.builder().build()) + .credentialsId(CREDENTIALS_ID) + .packagesService(packagesRmApiService) + .providersService(providersRmApiService) + .titlesService(titlesRmApiService) + .build(); + } + + @Test + void shouldRetrieveGlobalPackagesWhenProviderIdIsNull() { + var packages = Packages.builder().totalResults(0).build(); + when(packagesRmApiService.retrievePackages(any(), any())).thenReturn(completedFuture(packages)); + var filter = PackageRecordFilter.builder().filterTags(List.of("tag")).sort("relevance").page(1).count(10).build(); + + var result = result(packageService.retrievePackages(null, filter, context)); + + assertSame(packages, result); + verify(packagesRmApiService).retrievePackages(any(), any()); + verify(packagesRmApiService, never()).retrievePackages(any(Integer.class), any(), any()); + } + + @Test + void shouldRetrieveProviderScopedPackagesWhenProviderIdIsGiven() { + var packages = Packages.builder().totalResults(0).build(); + when(packagesRmApiService.retrievePackages(eq(42), any(), any())).thenReturn(completedFuture(packages)); + var filter = PackageRecordFilter.builder().filterTags(List.of("tag")).sort("relevance").page(1).count(10).build(); + + var result = result(packageService.retrievePackages(42, filter, context)); + + assertSame(packages, result); + verify(packagesRmApiService).retrievePackages(eq(42), any(), any()); + } + + @Test + void shouldResolveCustomProviderIdBeforeRetrievingPackages() { + var packages = Packages.builder().totalResults(0).build(); + when(customProviderIdService.getCustomProviderId(context)).thenReturn(completedFuture(111)); + when(packagesRmApiService.retrievePackages(eq(111), any(), any())).thenReturn(completedFuture(packages)); + var filter = PackageRecordFilter.builder().filterTags(List.of("tag")).sort("relevance").page(1).count(10).build(); + + var result = result(packageService.getCustomProviderIdAndRetrievePackages(filter, context)); + + assertSame(packages, result); + } + + @Test + void shouldCreateCustomPackageWithoutAccessType() { + var entity = buildPostRequest(null); + var packagePost = mock(PackagePost.class); + var postedPackage = PackageData.builder().vendorId(19).packageId(3964).build(); + when(requestConvertionService.convertCustomPackagePostRequest(entity)).thenReturn(packagePost); + when(customProviderIdService.getCustomProviderId(context)).thenReturn(completedFuture(19)); + when(packagesRmApiService.postPackage(packagePost, 19)).thenReturn(completedFuture(postedPackage)); + + var result = result(packageService.createCustomPackage(entity, context)); + + verify(validationService).validateCustomPackagePostRequest(entity); + assertSame(postedPackage, result.getPackageData()); + verify(packageAccessTypeService, never()).fetchAccessType(any(), any()); + verify(packageAccessTypeService, never()).assignAccessType(any(), any(), any()); + } + + @Test + void shouldCreateCustomPackageAndAssignAccessTypeWhenAccessTypeIdIsGiven() { + var entity = buildPostRequest("access-type-id"); + var packagePost = mock(PackagePost.class); + var postedPackage = PackageData.builder().vendorId(19).packageId(3964).build(); + var accessType = new AccessType(); + when(requestConvertionService.convertCustomPackagePostRequest(entity)).thenReturn(packagePost); + when(customProviderIdService.getCustomProviderId(context)).thenReturn(completedFuture(19)); + when(packagesRmApiService.postPackage(packagePost, 19)).thenReturn(completedFuture(postedPackage)); + when(packageAccessTypeService.fetchAccessType("access-type-id", context)).thenReturn(completedFuture(accessType)); + when(packageAccessTypeService.assignAccessType(eq(accessType), any(), eq(context))) + .thenAnswer(invocation -> completedFuture(invocation.getArgument(1))); + + var result = result(packageService.createCustomPackage(entity, context)); + + assertSame(postedPackage, result.getPackageData()); + verify(packageAccessTypeService).assignAccessType(eq(accessType), any(), eq(context)); + } + + @Test + void shouldRetrievePackageWithRelatedAccessTypeAndTags() { + var packageId = new PackageId(19, 3964); + var packageResult = new PackageResult(PackageData.builder().vendorId(19).packageId(3964).build()); + when(packagesRmApiService.retrievePackage(packageId, List.of("provider"))) + .thenReturn(completedFuture(packageResult)); + when(relatedEntitiesLoader.loadAccessType(eq(packageResult), any(), eq(context))) + .thenReturn(completedFuture(null)); + when(relatedEntitiesLoader.loadTags(eq(packageResult), any(), eq(context))).thenReturn(completedFuture(null)); + + var result = result(packageService.retrievePackageWithRelatedData(packageId, List.of("provider"), context)); + + assertSame(packageResult, result); + var expectedRecordKey = RecordKey.builder().recordId("19-3964").recordType(RecordType.PACKAGE).build(); + verify(relatedEntitiesLoader).loadAccessType(packageResult, expectedRecordKey, context); + verify(relatedEntitiesLoader).loadTags(packageResult, expectedRecordKey, context); + } + + @Test + void shouldThrowWhenIsCustomDoesNotMatchOriginalPackage() { + var packageId = new PackageId(19, 3964); + var originalPackage = PackageData.builder().vendorId(19).packageId(3964).isCustom(true).build(); + var entity = buildPutRequest(false, null); + when(packagesRmApiService.retrievePackage(3964)).thenReturn(completedFuture(originalPackage)); + + var packageUpdateFuture = packageService.updatePackage(packageId, entity, context); + var exception = assertThrows(TestFutureFailedException.class, () -> result(packageUpdateFuture)); + + assertEquals(InputValidationException.class, exception.getCause().getClass()); + verify(packagesRmApiService, never()).updatePackage(any(Integer.class), any()); + } + + @Test + void shouldUpdateCustomPackageAndAssignAccessType() { + var packageId = new PackageId(19, 3964); + var originalPackage = PackageData.builder().vendorId(19).packageId(3964).isCustom(true).build(); + var entity = buildPutRequest(true, "access-type-id"); + var packagePut = mock(PackagePut.class); + var accessType = new AccessType(); + when(packagesRmApiService.retrievePackage(3964)).thenReturn(completedFuture(originalPackage)); + when(packageAccessTypeService.fetchAccessType("access-type-id", context)).thenReturn(completedFuture(accessType)); + when(requestConvertionService.convertCustomPackagePutRequest(entity)).thenReturn(packagePut); + when(packagesRmApiService.updatePackage(3964, packagePut)).thenReturn(completedFuture(null)); + when(packageAccessTypeService.assignAccessType(eq(accessType), any(), eq(context))) + .thenAnswer(invocation -> completedFuture(invocation.getArgument(1))); + + var result = result(packageService.updatePackage(packageId, entity, context)); + + assertSame(originalPackage, result.getPackageData()); + verify(validationService).validateCustomPackagePutRequest(entity); + verify(packageAccessTypeService).assignAccessType(eq(accessType), any(), eq(context)); + } + + @Test + void shouldCleanUpLocalDataWhenPackageWasDeletedDuringUpdate() { + var packageId = new PackageId(19, 3964); + var originalPackage = PackageData.builder().vendorId(19).packageId(3964).isCustom(true).build(); + var entity = buildPutRequest(true, null); + var packagePut = mock(PackagePut.class); + var notFound = new ResourceNotFoundException("not found", 404, "Not Found", "", "query"); + var failedRetrieve = CompletableFuture.failedFuture(notFound); + when(packagesRmApiService.retrievePackage(3964)) + .thenReturn(completedFuture(originalPackage)) + .thenReturn(failedRetrieve); + when(requestConvertionService.convertCustomPackagePutRequest(entity)).thenReturn(packagePut); + when(packagesRmApiService.updatePackage(3964, packagePut)).thenReturn(completedFuture(null)); + when(packageAccessTypeService.updateRecordMapping(null, "19-3964", context)).thenReturn(completedFuture(null)); + when(packageTagsService.deletePackageTags(eq(packageId), any(), eq(TENANT))).thenReturn(completedFuture(null)); + + var packageUpdateFuture = packageService.updatePackage(packageId, entity, context); + assertThrows(TestFutureFailedException.class, () -> result(packageUpdateFuture)); + + verify(packageAccessTypeService).updateRecordMapping(null, "19-3964", context); + verify(packageTagsService).deletePackageTags(eq(packageId), any(), eq(TENANT)); + } + + @Test + void shouldThrowWhenDeletingNonCustomPackage() { + var packageId = new PackageId(19, 3964); + var packageData = PackageData.builder().vendorId(19).packageId(3964).isCustom(false).build(); + when(packagesRmApiService.retrievePackage(3964)).thenReturn(completedFuture(packageData)); + + var packageDeletionFuture = packageService.deletePackage(packageId, context); + var exception = assertThrows(TestFutureFailedException.class, () -> result(packageDeletionFuture)); + + assertEquals(InputValidationException.class, exception.getCause().getClass()); + verify(packagesRmApiService, never()).deletePackage(any(Integer.class)); + } + + @Test + void shouldDeleteCustomPackageAndAssignedResources() { + var packageId = new PackageId(19, 3964); + var packageData = PackageData.builder().vendorId(19).packageId(3964).isCustom(true).build(); + when(packagesRmApiService.retrievePackage(3964)).thenReturn(completedFuture(packageData)); + when(packagesRmApiService.deletePackage(3964)).thenReturn(completedFuture(null)); + when(packageAccessTypeService.updateRecordMapping(null, "19-3964", context)).thenReturn(completedFuture(null)); + when(packageTagsService.deletePackageTags(eq(packageId), any(), eq(TENANT))).thenReturn(completedFuture(null)); + + result(packageService.deletePackage(packageId, context)); + + verify(packagesRmApiService).deletePackage(3964); + verify(packageAccessTypeService).updateRecordMapping(null, "19-3964", context); + verify(packageTagsService).deletePackageTags(eq(packageId), any(), eq(TENANT)); + } + + @Test + void shouldValidateAndDelegateTagsUpdateToPackageTagsService() { + var credentialsId = UUID.randomUUID(); + var attributes = new PackageTagsDataAttributes().withName("name"); + var entity = new PackageTagsPutRequest().withData(new PackageTagsPutData().withAttributes(attributes)); + when(packageTagsService.updatePackageTags("19-3964", credentialsId, attributes, TENANT)) + .thenReturn(completedFuture(null)); + + var result = result(packageService.updateTagsForPackage(entity, credentialsId, "19-3964", TENANT)); + + assertSame(attributes, result); + verify(validationService).validatePackageTagsPutRequest(entity); + } + + private PackagePostRequest buildPostRequest(String accessTypeId) { + return new PackagePostRequest() + .withData(new PackagePostData() + .withAttributes(new PackagePostDataAttributes().withAccessTypeId(accessTypeId))); + } + + private PackagePutRequest buildPutRequest(boolean isCustom, String accessTypeId) { + return new PackagePutRequest() + .withData(new PackagePutData() + .withAttributes(new PackagePutDataAttributes() + .withIsCustom(isCustom) + .withAccessTypeId(accessTypeId))); + } +} diff --git a/src/test/java/org/folio/service/packages/PackageTagsServiceTest.java b/src/test/java/org/folio/service/packages/PackageTagsServiceTest.java new file mode 100644 index 00000000..efa9ffd2 --- /dev/null +++ b/src/test/java/org/folio/service/packages/PackageTagsServiceTest.java @@ -0,0 +1,121 @@ +package org.folio.service.packages; + +import static org.folio.util.TestUtil.result; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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 java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.folio.holdingsiq.model.PackageId; +import org.folio.repository.RecordType; +import org.folio.repository.packages.DbPackage; +import org.folio.repository.packages.PackageRepository; +import org.folio.repository.tag.DbTag; +import org.folio.repository.tag.TagRepository; +import org.folio.rest.jaxrs.model.ContentType; +import org.folio.rest.jaxrs.model.PackageTagsDataAttributes; +import org.folio.rest.jaxrs.model.Tags; +import org.folio.rmapi.result.TitleResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PackageTagsServiceTest { + + private static final String TENANT = "fs"; + private static final String PACKAGE_ID = "19-3964"; + private static final UUID CREDENTIALS_ID = UUID.randomUUID(); + + @Mock + private PackageRepository packageRepository; + @Mock + private TagRepository tagRepository; + @InjectMocks + private PackageTagsService packageTagsService; + + @Test + void shouldDoNothingWhenTagsAreNull() { + var attributes = new PackageTagsDataAttributes().withName("name").withContentType(ContentType.UNKNOWN); + + var result = result(packageTagsService.updatePackageTags(PACKAGE_ID, CREDENTIALS_ID, attributes, TENANT)); + + assertNull(result); + verifyNoInteractions(packageRepository); + verifyNoInteractions(tagRepository); + } + + @Test + void shouldSavePackageAndUpdateTagsWhenTagListIsNotEmpty() { + when(packageRepository.save(any(), eq(TENANT))).thenReturn(CompletableFuture.completedFuture(null)); + when(tagRepository.updateRecordTags(eq(TENANT), eq(PACKAGE_ID), eq(RecordType.PACKAGE), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + var attributes = new PackageTagsDataAttributes() + .withName("name") + .withContentType(ContentType.UNKNOWN) + .withTags(new Tags().withTagList(List.of("tag1", "tag2"))); + + var result = result(packageTagsService.updatePackageTags(PACKAGE_ID, CREDENTIALS_ID, attributes, TENANT)); + + assertNull(result); + verify(packageRepository).save(any(DbPackage.class), eq(TENANT)); + verify(packageRepository, never()).delete(any(), any(), any()); + verify(tagRepository).updateRecordTags(TENANT, PACKAGE_ID, RecordType.PACKAGE, List.of("tag1", "tag2")); + } + + @Test + void shouldDeleteStoredPackageAndUpdateTagsWhenTagListIsEmpty() { + when(packageRepository.delete(any(), any(), eq(TENANT))).thenReturn(CompletableFuture.completedFuture(null)); + when(tagRepository.updateRecordTags(eq(TENANT), eq(PACKAGE_ID), eq(RecordType.PACKAGE), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + var attributes = new PackageTagsDataAttributes() + .withName("name") + .withContentType(ContentType.UNKNOWN) + .withTags(new Tags().withTagList(List.of())); + + var result = result(packageTagsService.updatePackageTags(PACKAGE_ID, CREDENTIALS_ID, attributes, TENANT)); + + assertNull(result); + verify(packageRepository).delete(new PackageId(19, 3964), CREDENTIALS_ID, TENANT); + verify(packageRepository, never()).save(any(), any()); + verify(tagRepository).updateRecordTags(TENANT, PACKAGE_ID, RecordType.PACKAGE, List.of()); + } + + @Test + void shouldDeletePackageAndItsTags() { + var packageId = new PackageId(19, 3964); + when(packageRepository.delete(packageId, CREDENTIALS_ID, TENANT)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(tagRepository.deleteRecordTags(TENANT, PACKAGE_ID, RecordType.PACKAGE)) + .thenReturn(CompletableFuture.completedFuture(true)); + + var result = result(packageTagsService.deletePackageTags(packageId, CREDENTIALS_ID, TENANT)); + + assertNull(result); + verify(packageRepository).delete(packageId, CREDENTIALS_ID, TENANT); + verify(tagRepository).deleteRecordTags(TENANT, PACKAGE_ID, RecordType.PACKAGE); + } + + @Test + void shouldPopulateResourceTagsFromTagRepository() { + var titleResult = new TitleResult(null, false); + var resourceIdToTitle = Map.of("resource-1", titleResult); + var tags = List.of(DbTag.builder().value("tag1").recordType(RecordType.RESOURCE).build()); + when(tagRepository.findPerRecord(TENANT, List.of("resource-1"), RecordType.RESOURCE)) + .thenReturn(CompletableFuture.completedFuture(Map.of("resource-1", tags))); + + result(packageTagsService.loadResourceTags(TENANT, resourceIdToTitle)); + + assertEquals(tags, titleResult.getResourceTagList()); + } +} diff --git a/src/test/java/org/folio/service/sanitizer/HtmlSanitizerTest.java b/src/test/java/org/folio/service/sanitizer/HtmlSanitizerTest.java new file mode 100644 index 00000000..931868ef --- /dev/null +++ b/src/test/java/org/folio/service/sanitizer/HtmlSanitizerTest.java @@ -0,0 +1,65 @@ +package org.folio.service.sanitizer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class HtmlSanitizerTest { + + private final HtmlSanitizer sanitizer = new HtmlSanitizer(); + + @Test + void shouldReturnNullWhenTargetIsNull() { + assertNull(sanitizer.sanitize(null)); + } + + @Test + void shouldReturnBlankValueUnchanged() { + assertEquals(" ", sanitizer.sanitize(" ")); + } + + @ParameterizedTest + @MethodSource("testCasesWithoutChanges") + void shouldKeepContentWithoutChanges(String desc, String content) { + assertEquals(content, sanitizer.sanitize(content), desc); + } + + @Test + void shouldStripDisallowedTagButKeepItsText() { + assertEquals("some text", sanitizer.sanitize("
some text
")); + } + + @Test + void shouldStripScriptTagAndItsContent() { + assertEquals("

safe

", sanitizer.sanitize("

safe

")); + } + + @Test + void shouldStripDisallowedAttribute() { + assertEquals("

text

", sanitizer.sanitize("

text

")); + } + + @Test + void shouldStripHrefAttributeOnNonAnchorTag() { + assertEquals("

text

", sanitizer.sanitize("

text

")); + } + + private static Stream testCasesWithoutChanges() { + return Stream.of( + Arguments.of("should keep not html content as is", "some custom description"), + Arguments.of("should keep class and style attributes on allowed tag", + "

text

"), + Arguments.of("should keep href rel and target attributes on anchor", + "link"), + Arguments.of("should not pretty print output", "
  • one
  • two
"), + Arguments.of("should keep allowed tags", + "

paragraph

bolditalicunderline" + + "
  1. one
  • two

h1

h2

h3

line
break") + ); + } +}