diff --git a/common/src/main/java/org/entcore/common/conversation/LegacySearchVisibleRequest.java b/common/src/main/java/org/entcore/common/conversation/LegacySearchVisibleRequest.java new file mode 100644 index 0000000000..38b3b60097 --- /dev/null +++ b/common/src/main/java/org/entcore/common/conversation/LegacySearchVisibleRequest.java @@ -0,0 +1,42 @@ +package org.entcore.common.conversation; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class LegacySearchVisibleRequest { + private final String userId; + private final String search; + private final String language; + private final String parentMessageId; + + @JsonCreator + public LegacySearchVisibleRequest(@JsonProperty("userId") final String userId, + @JsonProperty("search") final String search, + @JsonProperty("language") final String language, + @JsonProperty("parentMessageId") final String parentMessageId) { + this.userId = userId; + this.search = search; + this.language = language; + this.parentMessageId = parentMessageId; + } + + public String getUserId() { + return userId; + } + + public String getSearch() { + return search; + } + + public String getLanguage() { + return language; + } + + public String getParentMessageId() { + return parentMessageId; + } +} diff --git a/common/src/main/java/org/entcore/common/user/UserUtils.java b/common/src/main/java/org/entcore/common/user/UserUtils.java index 28686d4579..7e588cc88a 100644 --- a/common/src/main/java/org/entcore/common/user/UserUtils.java +++ b/common/src/main/java/org/entcore/common/user/UserUtils.java @@ -20,16 +20,10 @@ package org.entcore.common.user; import com.fasterxml.jackson.databind.ObjectMapper; - import fr.wseduc.mongodb.MongoDb; import fr.wseduc.webutils.I18n; import fr.wseduc.webutils.Utils; -import static fr.wseduc.webutils.Utils.getOrElse; -import static fr.wseduc.webutils.Utils.handlerToAsyncHandler; -import static fr.wseduc.webutils.Utils.isEmpty; -import static fr.wseduc.webutils.Utils.isNotEmpty; import fr.wseduc.webutils.http.Renders; -import static fr.wseduc.webutils.http.Renders.unauthorized; import fr.wseduc.webutils.request.CookieHelper; import fr.wseduc.webutils.security.JWT; import fr.wseduc.webutils.security.SecureHttpServerRequest; @@ -49,7 +43,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import static org.entcore.common.http.filter.AppOAuthResourceProvider.getTokenId; import io.vertx.core.shareddata.LocalMap; import org.entcore.common.neo4j.Neo4j; @@ -58,6 +51,9 @@ import org.entcore.common.utils.StringUtils; import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -66,6 +62,13 @@ import java.util.Set; import java.util.stream.Collectors; +import static fr.wseduc.webutils.Utils.getOrElse; +import static fr.wseduc.webutils.Utils.handlerToAsyncHandler; +import static fr.wseduc.webutils.Utils.isEmpty; +import static fr.wseduc.webutils.Utils.isNotEmpty; +import static fr.wseduc.webutils.http.Renders.unauthorized; +import static org.entcore.common.http.filter.AppOAuthResourceProvider.getTokenId; + public class UserUtils { private static final Vertx vertx = Vertx.currentContext().owner(); @@ -235,6 +238,7 @@ public static void findVisibles(EventBus eb, String userId, String customReturn, public void handle(AsyncResult> res) { if (res.succeeded()) { JsonArray r = res.result().body(); + log.info("UserUtils.findVisibles - r.size = " + r.size()); // TODO JBER : exposer métrique if (acceptLanguage != null) { translateGroupsNames(r, acceptLanguage); } @@ -348,6 +352,106 @@ private static void formatPositions(JsonObject dbResult) { dbResult.put("positions", positions); } + public static JsonArray mapObjectToContact(final String profile, final JsonArray shareBookmarks, final JsonArray visible, final String acceptLanguage) { + final List usedInAll = Arrays.asList("TO", "CC", "CCI"); + final List usedInCCI = Collections.singletonList("CCI"); + + /* + final JsonArray sb = new JsonArray(); + if (shareBookmarks != null) { + for (String id: shareBookmarks.fieldNames()) { + final JsonArray value = shareBookmarks.getJsonArray(id); + if (value == null || value.size() < 2) { + continue; + } + final JsonObject r = new fr.wseduc.webutils.collections.JsonObject(); + r.put("id", id); + r.put("displayName", value.remove(0)); + r.put("type", "ShareBookmark"); + sb.add(r); + } + } + + final JsonArray res = !sb.isEmpty() ? sortShareBookmarksByName(sb) : new JsonArray(); + */ + + final JsonArray res = new JsonArray(); + for (Object o: shareBookmarks) { + if (!(o instanceof JsonObject)) continue; + JsonObject j = (JsonObject) o; + j.put("type", "ShareBookmark"); + j.put("usedIn", usedInAll); + res.add(j); + } + + for (Object o: visible) { + if (!(o instanceof JsonObject)) continue; + JsonObject j = (JsonObject) o; + if (j.getString("name") != null) { + j.remove("profile"); + j.remove("children"); + j.remove("classrooms"); + j.remove("disciplines"); + j.remove("functions"); + j.remove("relatives"); + + Object gt = j.remove("groupType"); + Object gp = j.remove("groupProfile"); + if (gt instanceof Iterable) { + for (Object gti: (Iterable) gt) { + if (gti != null && !"Group".equals(gti) && gti.toString().endsWith("Group")) { + j.put("groupType", gti); + if ("ProfileGroup".equals(gti)) { + j.put("profile", gp); + } + break; + } + } + } + + UserUtils.groupDisplayName(j, acceptLanguage); + j.put("displayName", j.getString("name")); + + if ("ManualGroup".equals(j.getString("groupType")) && "BroadcastGroup".equals(j.getString("subType"))) { + j.put("type", "BroadcastGroup"); + j.put("usedIn", usedInCCI); + } else { + j.put("type", "Group"); + j.put("usedIn", usedInAll); + } + } else { + j.put("type", "User"); + j.put("usedIn", usedInAll); + j.remove("groupProfile"); + j.remove("groupType"); + j.remove("nbUsers"); + if (profile.equals("Student")) { + j.remove("relatives"); + } + } + + j.remove("name"); + j.remove("groupDisplayName"); + j.remove("sortDisplayName"); + j.remove("sortWeight"); + j.remove("subjects"); + j.remove("subType"); + j.remove("sorted_children_names"); + j.remove("sorted_functions"); + j.remove("sorted_disciplines"); + + res.add(j); + } + return res; + } + + private static JsonArray sortShareBookmarksByName(JsonArray sb) { + List list = sb.getList(); + list.sort(Comparator.comparing(o -> o.getString("displayName"))); + + return new JsonArray(list); + } + public static void findUsersCanSeeMe(final EventBus eb, HttpServerRequest request, final Handler handler) { JsonObject m = new JsonObject() @@ -667,6 +771,13 @@ public void handle(JsonObject session) { }); } + /** + * Fetch the user's session information and return an unauthorized response if the user has no session. Therefore, + * there is no need to handle a failure of the returned Future. + * @param eb Event bus to be used to fetch the user's session + * @param request Caller's request + * @return The user's session information + */ public static Future getAuthenticatedUserInfos(EventBus eb, HttpServerRequest request) { final Promise promise = Promise.promise(); getSession(eb, request, session -> { diff --git a/communication/src/main/java/org/entcore/communication/Communication.java b/communication/src/main/java/org/entcore/communication/Communication.java index de27682ff3..8d07be1b1a 100644 --- a/communication/src/main/java/org/entcore/communication/Communication.java +++ b/communication/src/main/java/org/entcore/communication/Communication.java @@ -35,7 +35,7 @@ public void start(final Promise startPromise) throws Exception { TimelineHelper helper = new TimelineHelper(vertx, vertx.eventBus(), config); CommunicationController communicationController = new CommunicationController(); - communicationController.setCommunicationService(new DefaultCommunicationService(helper, config.getJsonArray("discoverVisibleExpectedProfile", new JsonArray()))); + communicationController.setCommunicationService(new DefaultCommunicationService(vertx, helper, config)); addController(communicationController); setDefaultResourceFilter(new CommunicationFilter()); diff --git a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java index f00c1b0a4c..22abde6ca0 100644 --- a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java +++ b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java @@ -32,14 +32,19 @@ import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.request.RequestUtils; +import io.vertx.core.CompositeFuture; import io.vertx.core.Handler; +import io.vertx.core.Promise; import io.vertx.core.eventbus.Message; import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import org.entcore.common.http.filter.AdminFilter; import org.entcore.common.http.filter.ResourceFilter; +import org.entcore.common.neo4j.Neo4j; import org.entcore.common.user.UserUtils; +import org.entcore.common.utils.StringUtils; import org.entcore.common.validation.StringValidation; import org.entcore.communication.filters.CommunicationDiscoverVisibleFilter; import org.entcore.communication.services.CommunicationService; @@ -47,8 +52,11 @@ import java.util.List; +import static fr.wseduc.webutils.Utils.getOrElse; import static fr.wseduc.webutils.Utils.isNotEmpty; import static org.entcore.common.http.response.DefaultResponseHandler.*; +import static org.entcore.common.neo4j.Neo4jResult.fullNodeMergeHandler; +import static org.entcore.common.neo4j.Neo4jResult.validResultHandler; public class CommunicationController extends BaseController { @@ -269,7 +277,7 @@ public void searchVisible(HttpServerRequest request) { "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + "HEAD(visibles.profiles) as profile, subjects" + nbUsers + groupTypes; communicationService.visibleUsers(user.getUserId(), null, expectedTypes, true, true, false, - preFilter, customReturn, params, user.getType(), visibles -> { + preFilter, customReturn, params, user.getType(), false, visibles -> { if (visibles.isRight()) { renderJson(request, UserUtils.translateAndGroupVisible(visibles.right().getValue(), @@ -875,4 +883,22 @@ public void addDiscoverVisibleGroupUsers(HttpServerRequest request) { }); } + /** + * Search entities (users, groups) visible by the requester. + * This endpoint can serve 2 different types of results based on the configuration : + * - the old format that used to be served by the module conversation + * - the new format which returns more data + * @param request Caller's HTTP request + */ + @Get("/visible/search") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void searchVisibleContacts(HttpServerRequest request) { + UserUtils.getAuthenticatedUserInfos(eb, request) + .onSuccess(userInfos -> { + final String query = request.params().get("query"); + communicationService.searchVisibles(userInfos, query, I18n.acceptLanguage(request)) + .onSuccess(visibles -> renderJson(request, visibles)) + .onFailure(th -> renderError(request, new JsonObject().put("error", th.getMessage()))); + }); + } } diff --git a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java index d1f3b5e689..cda9694b65 100644 --- a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java +++ b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java @@ -21,6 +21,7 @@ import fr.wseduc.webutils.Either; +import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; @@ -106,11 +107,12 @@ void applyDefaultRules(JsonArray structureIds, final Integer transactionId, fina void removeRules(String structureId, Handler> handler); void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, boolean myGroup, - boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, + boolean profile, String preFilter, String customReturn, JsonObject additionalParams, Handler> handler); void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, boolean myGroup, - boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, String userProfile, + boolean profile, String preFilter, String customReturn, JsonObject additionalParams, String userProfile, + boolean reverseUnion, Handler> handler); void usersCanSeeMe(String userId, final Handler> handler); @@ -167,5 +169,15 @@ void visibleManualGroups(String userId, String customReturn, JsonObject addition void addDiscoverVisibleGroupUsers(UserInfos user, String groupId, JsonObject body, HttpServerRequest request, Handler> handler); void getDiscoverVisibleAcceptedProfile(Handler> handler); + + + /** + * Search visible users. + * @param user Requester + * @param search Keyword to filter the search results + * @param language User's language + */ + Future searchVisibles(UserInfos user, String search, String language); + } diff --git a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java index f72bfccf09..5a6ecaf917 100644 --- a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java +++ b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java @@ -21,34 +21,41 @@ import fr.wseduc.webutils.Either; import fr.wseduc.webutils.collections.Joiner; -import io.vertx.core.Future; -import io.vertx.core.Handler; -import io.vertx.core.Promise; +import io.vertx.core.*; +import io.vertx.core.eventbus.EventBus; import io.vertx.core.eventbus.Message; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; +import org.entcore.common.conversation.LegacySearchVisibleRequest; import org.entcore.common.neo4j.Neo4j; import org.entcore.common.neo4j.StatementsBuilder; import org.entcore.common.notification.TimelineHelper; import org.entcore.common.user.DefaultFunctions; import org.entcore.common.user.UserInfos; +import org.entcore.common.user.UserUtils; +import org.entcore.common.utils.StringUtils; import org.entcore.common.validation.StringValidation; import org.entcore.communication.services.CommunicationService; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; -import static org.entcore.common.neo4j.Neo4jResult.*; +import static io.vertx.core.json.JsonObject.mapFrom; +import static org.entcore.common.neo4j.Neo4jResult.fullNodeMergeHandler; +import static org.entcore.common.neo4j.Neo4jResult.validEmptyHandler; +import static org.entcore.common.neo4j.Neo4jResult.validResultHandler; +import static org.entcore.common.neo4j.Neo4jResult.validUniqueResult; +import static org.entcore.common.neo4j.Neo4jResult.validUniqueResultHandler; public class DefaultCommunicationService implements CommunicationService { @@ -59,10 +66,14 @@ public class DefaultCommunicationService implements CommunicationService { private final TimelineHelper notifyTimeline; final JsonArray discoverVisibleExpectedProfile = new JsonArray(); + private final String visiblesSearchType; + private final EventBus eventBus; - public DefaultCommunicationService(TimelineHelper notifyTimeline, JsonArray discoverVisibleExpectedProfile) { + public DefaultCommunicationService(final Vertx vertx, final TimelineHelper notifyTimeline, final JsonObject config) { this.notifyTimeline = notifyTimeline; - this.discoverVisibleExpectedProfile.addAll(discoverVisibleExpectedProfile); + this.discoverVisibleExpectedProfile.addAll(config.getJsonArray("discoverVisibleExpectedProfile", new JsonArray())); + this.visiblesSearchType = config.getString("visibles-search-type", "light"); + this.eventBus = vertx.eventBus(); } @Override @@ -576,15 +587,16 @@ public void removeRules(String structureId, Handler> @Override public void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, - boolean myGroup, boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, + boolean myGroup, boolean profile, String preFilter, String customReturn, JsonObject additionalParams, final Handler> handler) { - visibleUsers(userId, structureId, expectedTypes, itSelf, myGroup, profile, preFilter, customReturn, additionnalParams, null, handler); + visibleUsers(userId, structureId, expectedTypes, itSelf, myGroup, profile, preFilter, customReturn, additionalParams, null, false, handler); } @Override - public void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, - boolean myGroup, boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, String userProfile, - final Handler> handler) { + public void visibleUsers( + String userId, String structureId, JsonArray expectedTypes, boolean itSelf, boolean myGroup, boolean profile, + String preFilter, String customReturn, JsonObject additionalParams, String userProfile, boolean reverseUnion, + final Handler> handler) { StringBuilder query = new StringBuilder(); JsonObject params = new JsonObject(); String condition = itSelf ? "" : "AND m.id <> {userId} "; @@ -616,7 +628,7 @@ public void visibleUsers(String userId, String structureId, JsonArray expectedTy } } query.append(condition); - if (expectedTypes != null && expectedTypes.size() > 0) { + if (expectedTypes != null && !expectedTypes.isEmpty()) { query.append("AND ("); StringBuilder types = new StringBuilder(); for (Object o: expectedTypes) { @@ -669,18 +681,23 @@ public void visibleUsers(String userId, String structureId, JsonArray expectedTy } } params.put("userId", userId); - if (additionnalParams != null) { - params.mergeIn(additionnalParams); + if (additionalParams != null) { + params.mergeIn(additionalParams); } String q; if (union != null) { - q = query.append(" union ").append(union.toString()).toString(); + if (reverseUnion) { + q = union.append(" union ").append(query).toString(); + } else { + q = query.append(" union ").append(union).toString(); + } } else { q = query.toString(); } neo4j.execute(q, params, validResultHandler(handler)); } + @Override public void usersCanSeeMe(String userId, Handler> handler) { String query = @@ -1489,4 +1506,458 @@ public void getDiscoverVisibleAcceptedProfile(Handler> handler.handle(new Either.Right<>(discoverVisibleExpectedProfile)); } + @Override + public Future searchVisibles(UserInfos user, String search, String language) { + final Future visibles; + switch (this.visiblesSearchType) { + case "legacy": + visibles = legacySearchVisible(user, search, language); + break; + case "complete": + visibles = searchVisibleContacts(user, search, language); + break; + case "optimized": + visibles = searchVisibleContactsOptimized(user, search, language); + break; + default: + visibles = searchVisibleContactsLight(user, search, language); + } + return visibles; + } + + private Future legacySearchVisible(final UserInfos user, final String search, final String language) { + final Promise promise = Promise.promise(); + // legacy visibles + final LegacySearchVisibleRequest payload = new LegacySearchVisibleRequest( + user.getUserId(), + search, language, + null + ); + Promise legacyVisiblesPromise = Promise.promise(); + eventBus.request("conversation.legacy.search.visible", mapFrom(payload), e -> { + if(e.succeeded()) { + legacyVisiblesPromise.complete((JsonObject) e.result().body()); + } else { + legacyVisiblesPromise.fail(e.cause()); + } + }); + // Share bookmarks + final Future> shareBookmarksFuture = getShareBookmarks(user, search); + + CompositeFuture.join(legacyVisiblesPromise.future(), shareBookmarksFuture) + .onComplete(ar -> { + if (ar.failed()) { + promise.fail(ar.cause().getMessage()); + } else { + + final JsonObject legacyResult = legacyVisiblesPromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + final JsonArray adaptedLegacyResult = adaptLegacyResultFormat(user.getType(), language, legacyResult, shareBookmarks); + promise.complete(adaptedLegacyResult); + } + }); + return promise.future(); + } + + private JsonArray adaptLegacyResultFormat( + final String userProfile, + final String language, + final JsonObject legacyResult, + final JsonArray shareBookmarks) { + final JsonArray visibles = new JsonArray(); + visibles.addAll(adaptLegacyUsersResult(legacyResult.getJsonArray("users", new JsonArray()))); + visibles.addAll(adaptLegacyGroupsResult(legacyResult.getJsonArray("groups", new JsonArray()))); + return UserUtils.mapObjectToContact( + userProfile, + shareBookmarks, + visibles, + language + ); + } + + private JsonArray adaptLegacyUsersResult(final JsonArray users) { + users.stream() + .map(user -> (JsonObject) user) + .forEach(user -> { + user.remove("structureName"); + user.put("name", null).put("groupType", new JsonArray().add("Visible").add("User")); + }); + return users; + } + + private JsonArray adaptLegacyGroupsResult(final JsonArray groups) { + groups.stream() + .map(group -> (JsonObject) group) + .forEach(group -> { + group.remove("structureName"); + group.put("groupType", new JsonArray().add("Visible").add("Group").add("ProfileGroup")).put("groupProfile", group.remove("profile")); + }); + return groups; + } + + public Future searchVisibleContacts(UserInfos user, String search, String language) { + final Promise promise = Promise.promise(); + String match = "MATCH (visibles) " + + + "OPTIONAL MATCH visibles-[:RELATED]->(parent: User) " + + "WITH DISTINCT visibles, collect({id: parent.id, displayName: parent.displayName}) as relatives " + + + "OPTIONAL MATCH visibles-[:IN]->(pg:ProfileGroup)-[:DEPENDS]->(c:Class) " + + "WITH DISTINCT visibles, relatives, collect({id: c.id, name: c.name}) as classrooms " + + + "OPTIONAL MATCH visibles<-[:RELATED]-(child: User) " + + "WITH visibles, relatives, classrooms, child " + + "ORDER BY child.displayName " + + "WITH visibles, relatives, classrooms, collect({id: child.id, displayName: child.displayName}) AS children, collect(distinct child.displayName) AS sorted_children " + + + "OPTIONAL MATCH visibles-[:IN]->(fg:FuncGroup) " + + "WITH visibles, relatives, classrooms, children, sorted_children, fg " + + "ORDER BY fg.filter " + + "WITH visibles, relatives, classrooms, children, sorted_children, collect({id: fg.id, name: fg.filter}) AS functions, collect(distinct fg.filter) AS sorted_functions " + + + "OPTIONAL MATCH visibles-[:IN]->(dg:DisciplineGroup) " + + "WITH visibles, relatives, classrooms, children, sorted_children, functions, sorted_functions, dg " + + "ORDER BY dg.filter " + + "WITH DISTINCT visibles, relatives, classrooms, children, sorted_children, functions, sorted_functions, collect({id: dg.id, name: dg.filter}) as disciplines, collect(distinct dg.filter) AS sorted_disciplines " + + + "WITH visibles, relatives, classrooms, children, functions, disciplines, " + + "reduce(s = '', name IN sorted_children | s + name) AS sorted_children_names, " + + "reduce(s = '', name IN sorted_functions | s + name) AS sorted_functions, " + + "reduce(s = '', name IN sorted_disciplines | s + name) AS sorted_disciplines "; + + String preFilter = ""; + JsonObject params = new JsonObject(); + + if (!StringUtils.isEmpty(search)) { + preFilter = "AND m.displayNameSearchField CONTAINS {search} "; + String sanitizedSearch = StringValidation.sanitize(search); + params.put("search", sanitizedSearch); + } + + final String customReturn = match + + "RETURN DISTINCT visibles.id as id, visibles.name as name, " + + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + + "HEAD(visibles.profiles) as profile, visibles.nbUsers as nbUsers, " + + "labels(visibles) as groupType, visibles.filter as groupProfile, visibles.subType as subType, " + + "filter(x IN coalesce(children, []) WHERE x.id IS NOT NULL) as children, " + + "filter(x IN coalesce(relatives, []) WHERE x.id IS NOT NULL) as relatives, " + + "filter(x IN coalesce(classrooms, []) WHERE x.id IS NOT NULL) as classrooms, " + + "filter(x IN coalesce(functions, []) WHERE x.id IS NOT NULL) as functions, " + + "filter(x IN coalesce(disciplines, []) WHERE x.id IS NOT NULL) as disciplines, " + + "sorted_children_names, sorted_functions, sorted_disciplines, " + + "CASE " + + "WHEN visibles.displayName IS NOT NULL THEN visibles.displayName " + + "WHEN visibles.name IS NOT NULL THEN visibles.name " + + "ELSE '' " + + "END as sortDisplayName, " + + "CASE " + + "WHEN HEAD(visibles.profiles) = 'Teacher' THEN 1 " + + "WHEN HEAD(visibles.profiles) = 'Personnel' THEN 2 " + + "WHEN HEAD(visibles.profiles) = 'Relative' THEN 3 " + + "WHEN HEAD(visibles.profiles) = 'Student' THEN 4 " + + "WHEN HEAD(visibles.profiles) = 'Guest' THEN 5 " + + "WHEN visibles.subType = 'BroadcastGroup' THEN 12 " + + "WHEN 'ManualGroup' IN labels(visibles) THEN 6 " + + "WHEN (visibles:ProfileGroup)-[:DEPENDS]->(:Class) THEN 7 " + + "WHEN (visibles:ProfileGroup)-[:DEPENDS]->(:Structure) THEN 11 " + + "WHEN 'FunctionalGroup' IN labels(visibles) THEN 8 " + + "WHEN 'FuncGroup' IN labels(visibles) OR 'FunctionGroup' IN labels(visibles) OR 'DirectionGroup' IN labels(visibles) THEN 9 " + + "WHEN 'DisciplineGroup' IN labels(visibles) THEN 10 " + + "ELSE 13 " + + "END as sortWeight " + + "ORDER BY " + + "sortWeight, " + + "toLower(sortDisplayName), " + + "sorted_children_names, " + + "sorted_functions, " + + " sorted_disciplines "; + + Promise> getVisiblePromise = Promise.promise(); + visibleUsers( + user.getUserId(), + null, + null, + true, + true, + false, + preFilter, + customReturn, + params, + user.getType(), + true, + getVisiblePromise::complete); + + // Share bookmarks + final Future> shareBookmarksFuture = getShareBookmarks(user, search); + + CompositeFuture.join(getVisiblePromise.future(), shareBookmarksFuture) + .onComplete(ar -> { + + if (ar.failed()) { + promise.fail(ar.cause()); + } else { + + final Either resVisible = getVisiblePromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); + + if (resVisible.isLeft()) { + log.error(resVisible.left().getValue()); + } + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + + JsonArray visible = resVisible.isLeft() ? new JsonArray() : resVisible.right().getValue(); + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + promise.complete( + UserUtils.mapObjectToContact( + user.getType(), + shareBookmarks, + visible, + language + ) + ); + } + }); + return promise.future(); + } + + public Future searchVisibleContactsLight(UserInfos user, String search, String language) { + final Promise promise = Promise.promise(); + String match = "MATCH (visibles) " + + + "OPTIONAL MATCH visibles-[:RELATED]->(parent: User) " + + "WITH DISTINCT visibles, collect({id: parent.id, displayName: parent.displayName}) as relatives " + + + "OPTIONAL MATCH visibles<-[:RELATED]-(child: User) " + + "WITH visibles, relatives, child " + + "ORDER BY child.displayName " + + "WITH visibles, relatives, collect({id: child.id, displayName: child.displayName}) AS children, collect(distinct child.displayName) AS sorted_children " + + "WITH visibles, relatives, children, reduce(s = '', name IN sorted_children | s + name) AS sorted_children_names "; + + String preFilter = ""; + JsonObject params = new JsonObject(); + + if (!StringUtils.isEmpty(search)) { + preFilter = "AND m.displayNameSearchField CONTAINS {search} "; + String sanitizedSearch = StringValidation.sanitize(search); + params.put("search", sanitizedSearch); + } + + final String customReturn = match + + "RETURN DISTINCT visibles.id as id, visibles.name as name, " + + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + + "HEAD(visibles.profiles) as profile, visibles.nbUsers as nbUsers, " + + "labels(visibles) as groupType, visibles.filter as groupProfile, visibles.subType as subType, " + + "filter(x IN coalesce(children, []) WHERE x.id IS NOT NULL) as children, " + + "filter(x IN coalesce(relatives, []) WHERE x.id IS NOT NULL) as relatives, " + + "sorted_children_names, " + + "CASE " + + "WHEN visibles.displayName IS NOT NULL THEN visibles.displayName " + + "WHEN visibles.name IS NOT NULL THEN visibles.name " + + "ELSE '' " + + "END as sortDisplayName " + + "ORDER BY " + + "toLower(sortDisplayName) "; + + Promise> getVisiblePromise = Promise.promise(); + visibleUsers( + user.getUserId(), + null, + null, + true, + true, + false, + preFilter, + customReturn, + params, + user.getType(), + true, + getVisiblePromise::complete); + + // Share bookmarks + final Future> shareBookmarksFuture = getShareBookmarks(user, search); + + CompositeFuture.join(getVisiblePromise.future(), shareBookmarksFuture) + .onComplete(ar -> { + + if (ar.failed()) { + promise.fail(ar.cause().getMessage()); + } else { + + final Either resVisible = getVisiblePromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); + + if (resVisible.isLeft()) { + log.error(resVisible.left().getValue()); + } + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + + JsonArray visible = resVisible.isLeft() ? new JsonArray() : resVisible.right().getValue(); + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + promise.complete( + UserUtils.mapObjectToContact( + user.getType(), + shareBookmarks, + visible, + language + ) + ); + } + }); + return promise.future(); + } + + + public Future searchVisibleContactsOptimized(UserInfos user, String search, String language) { + final Promise promise = Promise.promise(); + String match = "MATCH (visibles) " + + "WITH visibles, HEAD(visibles.profiles) AS primaryProfile " + + "OPTIONAL MATCH (visibles)-[:RELATED]-(related:User) " + + "WITH visibles, primaryProfile, " + + " collect(CASE \n" + + " WHEN primaryProfile = 'Student' THEN {id: related.id, displayName: related.displayName, role: 'parent'} \n" + + " WHEN primaryProfile <> 'Student' THEN {id: related.id, displayName: related.displayName, role: 'child'}\n" + + " END) AS relatedUsers\n" + + "WITH visibles, \n" + + " [x IN relatedUsers WHERE x.role = 'parent'] AS relatives, \n" + + " [x IN relatedUsers WHERE x.role = 'child'] AS children " + + "OPTIONAL MATCH (visibles)-[:IN]->(pg:ProfileGroup)-[:DEPENDS]->(c:Class) " + + "WITH visibles, relatives, children \n" + + "OPTIONAL MATCH (visibles)-[:IN]->(fdg:Group) WHERE fdg:FuncGroup OR fdg:DisciplineGroup " + + "WITH visibles, " + + " relatives, " + + " children, " + + " collect(DISTINCT {id: c.id, name: c.name}) as classrooms, " + + " collect(CASE " + + " WHEN fdg:FuncGroup AND fdg.id IS NOT NULL THEN {id: fdg.id, name: fdg.filter, type: 'function'} " + + " WHEN fdg:DisciplineGroup AND fdg.id IS NOT NULL THEN {id: fdg.id, name: fdg.filter, typ: 'discipline'}" + + " END) AS groups " + + "WITH visibles, " + + " relatives," + + " children," + + " classrooms, " + + " [g IN groups WHERE g.type = 'function'] AS functions, " + + " [g IN groups WHERE g.type = 'discipline'] AS disciplines "; + + String preFilter = ""; + JsonObject params = new JsonObject(); + + if (!StringUtils.isEmpty(search)) { + preFilter = "AND m.displayNameSearchField CONTAINS {search} "; + String sanitizedSearch = StringValidation.sanitize(search); + params.put("search", sanitizedSearch); + } + + final String customReturn = match + + " WITH visibles, \n" + + " labels(visibles) AS visibleLabels, \n" + + " HEAD(visibles.profiles) AS primaryProfile, \n" + + " filter(x IN coalesce(children, []) WHERE x.id IS NOT NULL) as validChildren, \n" + + " filter(x IN coalesce(relatives, []) WHERE x.id IS NOT NULL) as validRelatives, \n" + + " filter(x IN coalesce(classrooms, []) WHERE x.id IS NOT NULL) as validClassrooms, \n" + + " filter(x IN coalesce(functions, []) WHERE x.id IS NOT NULL) as validFunctions, \n" + + " filter(x IN coalesce(disciplines, []) WHERE x.id IS NOT NULL) as validDisciplines\n" + + "RETURN DISTINCT \n" + + " visibles.id as id, \n" + + " visibles.name as name, \n" + + " visibles.displayName as displayName, \n" + + " visibles.groupDisplayName as groupDisplayName, \n" + + " primaryProfile as profile, \n" + + " visibles.nbUsers as nbUsers, \n" + + " visibleLabels as groupType, \n" + + " visibles.filter as groupProfile, \n" + + " visibles.subType as subType, \n" + + " validChildren as children, \n" + + " validRelatives as relatives, \n" + + " validClassrooms as classrooms, \n" + + " validFunctions as functions, \n" + + " validDisciplines as disciplines \n"; + + Promise> getVisiblePromise = Promise.promise(); + visibleUsers( + user.getUserId(), + null, + null, + true, + true, + false, + preFilter, + customReturn, + params, + user.getType(), + true, + getVisiblePromise::complete); + + // Share bookmarks + final Future> shareBookmarksFuture = getShareBookmarks(user, search); + + CompositeFuture.join(getVisiblePromise.future(), shareBookmarksFuture) + .onComplete(ar -> { + + if (ar.failed()) { + promise.fail(ar.cause()); + } else { + + final Either resVisible = getVisiblePromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); + + if (resVisible.isLeft()) { + log.error(resVisible.left().getValue()); + } + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + + JsonArray visible = resVisible.isLeft() ? new JsonArray() : resVisible.right().getValue(); + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + promise.complete( + UserUtils.mapObjectToContact( + user.getType(), + shareBookmarks, + visible, + language + ) + ); + } + }); + return promise.future(); + } + + private static Future> getShareBookmarks(UserInfos user, String search) { + final Promise> getShareBookmarksPromise = Promise.promise(); + + String sbFilter = ""; + JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); + if (!StringUtils.isEmpty(search)) { + sbFilter = " AND lower(sbValue[0]) contains {search} "; + sbParams.put("search", StringValidation.sanitize(search)); + } + String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + + "WITH sb, keys(sb) AS ids " + + "UNWIND ids AS id " + + "WITH sb, id, sb[id] AS sbValue " + + "WHERE size(sbValue) >= 2 " + sbFilter + + "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + + "RETURN id as id, sbValue[0] as displayName "; + + Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); + return getShareBookmarksPromise.future(); + } + + } diff --git a/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java b/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java index 70ff43a5db..7dcd44020e 100644 --- a/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java +++ b/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java @@ -42,7 +42,7 @@ private UserInfos mockUserNoAdmin() { @Before public void prepare() { - this.service = new DefaultCommunicationService(new TimelineHelper(Vertx.vertx(), Vertx.vertx().eventBus(), new JsonObject()), new JsonArray()); + this.service = new DefaultCommunicationService(Vertx.vertx(), new TimelineHelper(Vertx.vertx(), Vertx.vertx().eventBus(), new JsonObject()), new JsonObject()); this.userNoAdmin = test.directory().generateUser("notused"); this.userNoAdmin.setFunctions(new HashMap(0)); } diff --git a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java index 57514bf30b..39eaacc6aa 100644 --- a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java +++ b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java @@ -77,7 +77,7 @@ public static void setUpAll(TestContext context) throws Exception { //@Before public void setUp(TestContext context) { vertx = test.vertx(); - defaultComService = new DefaultCommunicationService(new TimelineHelper(vertx, vertx.eventBus(), new JsonObject()), new JsonArray()); + defaultComService = new DefaultCommunicationService(Vertx.vertx(), new TimelineHelper(vertx, vertx.eventBus(), new JsonObject()), new JsonObject()); } //@Test @@ -162,7 +162,7 @@ private Future getComRules(CommunicationService communicationService, Stri vertx.setTimer(i * 50L, h -> { final long start = System.currentTimeMillis(); communicationService.visibleUsers(userId, null, null, true, true, - false, null, CUSTOM_RETURN, new JsonObject(), userProfile, visibles -> { + false, null, CUSTOM_RETURN, new JsonObject(), userProfile, false, visibles -> { if (visibles.isRight()) { final JsonObject j = new JsonObject() .put("visibles", visibles.right().getValue()) diff --git a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java index 4dd0439552..8ddf490314 100644 --- a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java +++ b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java @@ -34,6 +34,7 @@ import io.vertx.core.http.HttpServerRequest; import org.entcore.common.editor.IContentTransformerEventRecorder; +import org.entcore.common.conversation.LegacySearchVisibleRequest; import org.entcore.common.sql.Sql; import org.entcore.common.sql.SqlResult; import org.entcore.common.sql.SqlStatementsBuilder; @@ -96,6 +97,17 @@ public SqlConversationService(Vertx vertx, String schema, IContentTransformerCli optimizedThreadList = vertx.getOrCreateContext().config().getBoolean("optimized-thread-list", false); this.contentTransformerClient = contentTransformerClient; this.contentTransformerEventRecorder = contentTransformerEventRecorder; + eb.consumer("conversation.legacy.search.visible", message -> { + final JsonObject payload = (JsonObject) message.body(); + final LegacySearchVisibleRequest request = payload.mapTo(LegacySearchVisibleRequest.class); + this.doFindVisibleRecipients(request.getParentMessageId(), request.getUserId(), + request.getLanguage(), request.getSearch()) + .onSuccess(message::reply) + .onFailure(th -> { + log.warn("An error occurred while finding visibles", th); + message.fail(500, th.getMessage()); + }); + }); } public SqlConversationService setSendTimeout(int sendTimeout) { @@ -318,7 +330,7 @@ public void handle(Either event) { @Override public void list(String folder, Boolean unread, UserInfos user, int page, int pageSize, final String searchText, Handler> results) { - list(folder, + list(folder, ConversationService.isSystemFolder(folder) ? null : "", // `restrain` can only applies to user's folders. unread, user, page, pageSize, searchText, results ); @@ -919,12 +931,22 @@ public void count(String folder, String restrain, Boolean unread, UserInfos user sql.prepared(query, values, SqlResult.validUniqueResultHandler(result)); } + + @Override public void findVisibleRecipients(final String parentMessageId, final UserInfos user, final String acceptLanguage, final String search, final Handler> result) { - if (validationParamsError(user, result)) + if (validationParamsError(user, result)) { return; + } + doFindVisibleRecipients(parentMessageId, user.getUserId(), acceptLanguage, search) + .onSuccess(data -> result.handle(new Either.Right<>(data))) + .onFailure(th -> result.handle(new Either.Left<>(th.getMessage()))); + } + private Future doFindVisibleRecipients(final String parentMessageId, final String userId, + final String acceptLanguage, final String search) { + final Promise promise = Promise.promise(); final JsonObject visible = new JsonObject(); final JsonObject params = new JsonObject(); @@ -944,7 +966,7 @@ public void findVisibleRecipients(final String parentMessageId, final UserInfos SqlResult.validUniqueResultHandler(new Handler>() { public void handle(Either event) { if(event.isLeft()){ - result.handle(event); + promise.fail(event.left().getValue()); return; } @@ -960,7 +982,7 @@ public void handle(Either event) { "RETURN DISTINCT visibles.id as id, visibles.name as name, " + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + "visibles.profiles[0] as profile, visibles.structureName as structureName, visibles.filter as groupProfile "; - callFindVisibles(user, acceptLanguage, result, visible, params, preFilter, customReturn); + callFindVisibles(userId, acceptLanguage, visible, params, preFilter, customReturn).onComplete(promise); } })); } else { @@ -968,41 +990,45 @@ public void handle(Either event) { "RETURN DISTINCT visibles.id as id, visibles.name as name, " + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + "visibles.profiles[0] as profile, visibles.structureName as structureName, visibles.filter as groupProfile"; - callFindVisibles(user, acceptLanguage, result, visible, params, preFilter, customReturn); + callFindVisibles(userId, acceptLanguage, visible, params, preFilter, customReturn).onComplete(promise); } + return promise.future(); } - private void callFindVisibles(UserInfos user, final String acceptLanguage, final Handler> result, + private Future callFindVisibles(final String userId, final String acceptLanguage, final JsonObject visible, JsonObject params, String preFilter, String customReturn) { - findVisibles(eb, user.getUserId(), customReturn, params, true, true, false, acceptLanguage, preFilter, new Handler() { - @Override - public void handle(JsonArray visibles) { - JsonArray users = new fr.wseduc.webutils.collections.JsonArray(); - JsonArray groups = new fr.wseduc.webutils.collections.JsonArray(); - visible.put("groups", groups).put("users", users); - for (Object o: visibles) { - if (!(o instanceof JsonObject)) continue; - JsonObject j = (JsonObject) o; - // NOTE: the management rule below is "if a visible JsonObject has a non-null *name* field, then it is a Group". - // TODO It should be defined more clearly. See #39835 - if (j.getString("name") != null) { - if( j.getString("groupProfile") == null ) { - // This is a Manual group, without a clearly defined "profile" (neither Student nor Teacher nor...) => Set it as "Manual" - j.put("groupProfile", "Manual"); - } - j.remove("displayName"); - UserUtils.groupDisplayName(j, acceptLanguage); - j.put("profile", j.remove("groupProfile")); // JCBE: set the *profile* field for this Group. - groups.add(j); - } else { - j.remove("name"); - j.remove("groupProfile"); // JCBE: remove this unused and empty data for a User. - users.add(j); - } - } - result.handle(new Either.Right(visible)); - } - }); + final Promise promise = Promise.promise(); + findVisibles(eb, userId, customReturn, params, true, true, false, acceptLanguage, preFilter, visibles -> { + JsonArray users = new fr.wseduc.webutils.collections.JsonArray(); + JsonArray groups = new fr.wseduc.webutils.collections.JsonArray(); + visible.put("groups", groups).put("users", users); + + log.info("callFindVisibles Count = " + visibles.size()); + + for (Object o: visibles) { + if (!(o instanceof JsonObject)) continue; + JsonObject j = (JsonObject) o; + // NOTE: the management rule below is "if a visible JsonObject has a non-null *name* field, then it is a Group". + // TODO It should be defined more clearly. See #39835 + + if (j.getString("name") != null) { + if( j.getString("groupProfile") == null ) { + // This is a Manual group, without a clearly defined "profile" (neither Student nor Teacher nor...) => Set it as "Manual" + j.put("groupProfile", "Manual"); + } + j.remove("displayName"); + UserUtils.groupDisplayName(j, acceptLanguage); + j.put("profile", j.remove("groupProfile")); // JCBE: set the *profile* field for this Group. + groups.add(j); + } else { + j.remove("name"); + j.remove("groupProfile"); // JCBE: remove this unused and empty data for a User. + users.add(j); + } + } + promise.complete(visible); + }); + return promise.future(); } @Override @@ -1226,8 +1252,8 @@ public void getFolderTree(final UserInfos user, int depth, final Optional getFolder(final String folderId) { Promise promise = Promise.promise(); sql.prepared( - "SELECT f.* FROM " + folderTable + " AS f WHERE f.id = ?", - new JsonArray().add(folderId), + "SELECT f.* FROM " + folderTable + " AS f WHERE f.id = ?", + new JsonArray().add(folderId), SqlResult.validUniqueResultHandler(either -> { if( either.isLeft() ) { promise.fail(either.left().getValue()); diff --git a/directory/src/main/resources/public/ts/controllers/directory.ts b/directory/src/main/resources/public/ts/controllers/directory.ts index bfaf193db7..d789129ca8 100644 --- a/directory/src/main/resources/public/ts/controllers/directory.ts +++ b/directory/src/main/resources/public/ts/controllers/directory.ts @@ -229,7 +229,7 @@ export const directoryController = ng.controller('DirectoryController',['$scope' $scope.generateCriteriaOptions = function(filters) { var test; return { - structures: $scope.criteria.structures.map((element) => { + structures: $scope.criteria.structures?.map((element) => { return { label: element.name, type: element.id }; }), classes: $scope.criteria.classes && $scope.criteria.classes.map((element) => { diff --git a/pom.xml b/pom.xml index 278ea3a9a3..63882a2bac 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ - 6.5-SNAPSHOT + 6.5-develop-b2school-SNAPSHOT 4.13.2 1.19.3 2.0-SNAPSHOT diff --git a/tests/src/test/js/it/scenarios/communication/_search-visibles-legacy.ts b/tests/src/test/js/it/scenarios/communication/_search-visibles-legacy.ts new file mode 100644 index 0000000000..77908ed758 --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/_search-visibles-legacy.ts @@ -0,0 +1,63 @@ +import { sleep } from "k6"; +import { describe } from "https://jslib.k6.io/k6chaijs/4.3.4.0/index.js"; + +import { + authenticateWeb, + getAdmlsOrMakThem, + triggerImport, + initStructure, + searchVisibles, +} from "../../../node_modules/edifice-k6-commons/dist/index.js"; +import { checkSearchVisible } from "./_utils.ts"; + +const aafImport = (__ENV.AAF_IMPORT || "true") === "true"; +const aafImportPause = parseInt(__ENV.AAF_IMPORT_PAUSE || "10"); +const maxDuration = __ENV.MAX_DURATION || "1m"; +const schoolName = __ENV.DATA_SCHOOL_NAME || "Search visibles - Tests"; +const gracefulStop = parseInt(__ENV.GRACEFUL_STOP || "2s"); + +export const options = { + setupTimeout: "1h", + thresholds: { + checks: ["rate == 1.00"], + }, + scenarios: { + searchVisiblesLegacy: { + exec: 'searchVisiblesLegacy', + executor: "per-vu-iterations", + vus: 1, + maxDuration: maxDuration, + gracefulStop, + }, + }, +}; + +export function setup() { + let structure; + describe("[Search visibles legacy] Initialize data", () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + structure = initStructure(schoolName) + }); + if(aafImport) { + triggerImport() + sleep(aafImportPause) + } + return {structure} +} + +/* For this test to complete successfully, recette must run with the following configuration in communication config section of ent-core.json : + - "visibles-search-type": "legacy" + */ +export function searchVisiblesLegacy(data) { + const {structure} = data; + describe('[Communication] Test - Search visibles legacy', () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + + const admlTeacher = getAdmlsOrMakThem(structure, 'Teacher', 1, [])[0] + authenticateWeb(admlTeacher.login) + const res = searchVisibles(); + const expectedUserFields = ["id", "displayName", "profile", "type", "usedIn"]; + const expectedGroupFields = ["id", "displayName", "profile", "type", "groupType", "usedIn"]; + checkSearchVisible(res, expectedUserFields, expectedGroupFields, 'Search visibles legacy') + }); +} \ No newline at end of file diff --git a/tests/src/test/js/it/scenarios/communication/_search-visibles-light.ts b/tests/src/test/js/it/scenarios/communication/_search-visibles-light.ts new file mode 100644 index 0000000000..9f071dfba6 --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/_search-visibles-light.ts @@ -0,0 +1,63 @@ +import { sleep } from "k6"; +import { describe } from "https://jslib.k6.io/k6chaijs/4.3.4.0/index.js"; + +import { + authenticateWeb, + getAdmlsOrMakThem, + triggerImport, + initStructure, + searchVisibles, +} from "../../../node_modules/edifice-k6-commons/dist/index.js"; +import { checkSearchVisible } from "./_utils.ts"; + +const aafImport = (__ENV.AAF_IMPORT || "true") === "true"; +const aafImportPause = parseInt(__ENV.AAF_IMPORT_PAUSE || "10"); +const maxDuration = __ENV.MAX_DURATION || "1m"; +const schoolName = __ENV.DATA_SCHOOL_NAME || "Search visibles - Tests"; +const gracefulStop = parseInt(__ENV.GRACEFUL_STOP || "2s"); + +export const options = { + setupTimeout: "1h", + thresholds: { + checks: ["rate == 1.00"], + }, + scenarios: { + searchVisiblesLight: { + exec: 'searchVisiblesLight', + executor: "per-vu-iterations", + vus: 1, + maxDuration: maxDuration, + gracefulStop, + }, + }, +}; + +export function setup() { + let structure; + describe("[Search visibles light] Initialize data", () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + structure = initStructure(schoolName) + }); + if(aafImport) { + triggerImport() + sleep(aafImportPause) + } + return {structure} +} + +/* For this test to complete successfully, recette must run with the following configuration in communication config section of ent-core.json : + - "visibles-search-type": "light" + */ +export function searchVisiblesLight(data) { + const {structure} = data; + describe('[Communication] Test - Search visibles light', () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + + const admlTeacher = getAdmlsOrMakThem(structure, 'Teacher', 1, [])[0] + authenticateWeb(admlTeacher.login) + const res = searchVisibles(); + const expectedUserFields = ["id", "displayName", "profile", "children", "relatives", "type", "usedIn"]; + const expectedGroupFields = ["id", "displayName", "profile", "nbUsers", "type", "groupType", "usedIn"]; + checkSearchVisible(res, expectedUserFields, expectedGroupFields, 'Search visibles light') + }); +} diff --git a/tests/src/test/js/it/scenarios/communication/_utils.ts b/tests/src/test/js/it/scenarios/communication/_utils.ts new file mode 100644 index 0000000000..4ced44741d --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/_utils.ts @@ -0,0 +1,23 @@ +import { check } from "k6"; +import { + Visible, + } from "../../../node_modules/edifice-k6-commons/dist/index.js"; + + +export function checkSearchVisible(res, expectedUserFields:string[], expectedGroupFields:string[], checkName) { + const checks = {} + const visibles: Visible[] = JSON.parse(res.body); + checks[`${checkName} - HTTP status`] = (r) => r.status === 200 + checks[`${checkName} - Visible user has expected fields`] = () => { + const visibleUser:Visible = visibles.filter(v => v.type === 'User')[0] + return expectedUserFields.every(field => visibleUser.hasOwnProperty(field)) + } + checks[`${checkName} - Visible group has expected fields`] = () => { + const visibleGroup:Visible = visibles.filter(v => v.type === 'Group')[0] + return expectedGroupFields.every(field => visibleGroup.hasOwnProperty(field)) + } + const ok = check(res, checks); + if(!ok) { + console.error(checkName, res) + } +} \ No newline at end of file diff --git a/tests/src/test/js/it/scenarios/communication/search_visibles.sh b/tests/src/test/js/it/scenarios/communication/search_visibles.sh new file mode 100755 index 0000000000..c9e21bd585 --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/search_visibles.sh @@ -0,0 +1,37 @@ +#!/bin/bash +data_dir="$1" +sb_dir="$2" +current_dir="$(pwd)" +exit_code=0 +# set DEBUG_SUSPEND=n in docker-compose.yml +sed -i 's/DEBUG_SUSPEND=y/DEBUG_SUSPEND=n/' $sb_dir/docker-compose.yml + +# set "visibles-search-type": "legacy" in ent-core.json +sed -i 's/"visibles-search-type.*"/"visibles-search-type": "legacy"/' $sb_dir/ent-core.json +# restart vertx container +cd $sb_dir +docker compose up -d --force-recreate vertx +# wait for vertx to restart +sleep 15 +# run k6 test for _search-visibles-legacy.ts +cd $current_dir +docker compose run --rm k6 run --compatibility-mode=experimental_enhanced file:///home/k6/src/it/scenarios/communication/_search-visibles-legacy.ts +if [ $? -ne 0 ]; then + exit_code=1 +fi + +# set "visibles-search-type": "light" in ent-core.json +sed -i 's/"visibles-search-type.*"/"visibles-search-type": "light"/' $sb_dir/ent-core.json +# restart vertx container +cd $sb_dir +docker compose up -d --force-recreate vertx +# wait for vertx to restart +sleep 15 +# run k6 test for _search-visibles-light.ts +cd $current_dir +docker compose run --rm k6 run --compatibility-mode=experimental_enhanced file:///home/k6/src/it/scenarios/communication/_search-visibles-light.ts +if [ $? -ne 0 ]; then + exit_code=1 +fi + +exit $exit_code