diff --git a/gradle/changelog/search.yaml b/gradle/changelog/search.yaml new file mode 100644 index 0000000000..9f20ca419b --- /dev/null +++ b/gradle/changelog/search.yaml @@ -0,0 +1,4 @@ +- type: Added + description: Add users and groups to default search index ([#1738](https://github.com/scm-manager/scm-manager/pull/1738)) +- type: Added + description: Add dedicated search page with more details and different types ([#1738](https://github.com/scm-manager/scm-manager/pull/1738)) diff --git a/scm-core/src/main/java/sonia/scm/group/Group.java b/scm-core/src/main/java/sonia/scm/group/Group.java index a3f5ca9fc0..83a07a5af0 100644 --- a/scm-core/src/main/java/sonia/scm/group/Group.java +++ b/scm-core/src/main/java/sonia/scm/group/Group.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.group; //~--- non-JDK imports -------------------------------------------------------- @@ -34,6 +34,8 @@ import com.google.common.collect.Lists; import sonia.scm.BasicPropertiesAware; import sonia.scm.ModelObject; import sonia.scm.ReducedModelObject; +import sonia.scm.search.Indexed; +import sonia.scm.search.IndexedType; import sonia.scm.util.Util; import sonia.scm.util.ValidationUtil; @@ -53,6 +55,7 @@ import java.util.List; * * @author Sebastian Sdorra */ +@IndexedType(permission = "group:list") @StaticPermissions( value = "group", globalPermissions = {"create", "list", "autocomplete"}, @@ -484,18 +487,22 @@ public class Group extends BasicPropertiesAware private boolean external = false; /** timestamp of the creation date of this group */ + @Indexed private Long creationDate; /** description of this group */ + @Indexed(defaultQuery = true, highlighted = true) private String description; /** timestamp of the last modified date of this group */ + @Indexed private Long lastModified; /** members of this group */ private List members; /** name of this group */ + @Indexed(defaultQuery = true, boost = 1.5f) private String name; /** type of this group */ diff --git a/scm-core/src/main/java/sonia/scm/search/HandlerEventIndexSyncer.java b/scm-core/src/main/java/sonia/scm/search/HandlerEventIndexSyncer.java new file mode 100644 index 0000000000..4832e3d448 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/search/HandlerEventIndexSyncer.java @@ -0,0 +1,66 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.search; + +import sonia.scm.HandlerEventType; +import sonia.scm.event.HandlerEvent; + +/** + * Keep index in sync with {@link HandlerEvent}. + * + * @param type of indexed item + * @since 2.22.0 + */ +public final class HandlerEventIndexSyncer { + + private final Indexer indexer; + + public HandlerEventIndexSyncer(Indexer indexer) { + this.indexer = indexer; + } + + /** + * Update index based on {@link HandlerEvent}. + * + * @param event handler event + */ + public void handleEvent(HandlerEvent event) { + HandlerEventType type = event.getEventType(); + if (type.isPost()) { + updateIndex(type, event.getItem()); + } + } + + private void updateIndex(HandlerEventType type, T item) { + try (Indexer.Updater updater = indexer.open()) { + if (type == HandlerEventType.DELETE) { + updater.delete(item); + } else { + updater.store(item); + } + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/search/Indexer.java b/scm-core/src/main/java/sonia/scm/search/Indexer.java new file mode 100644 index 0000000000..80b5202308 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/search/Indexer.java @@ -0,0 +1,102 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.search; + +import sonia.scm.plugin.ExtensionPoint; + +/** + * Indexer for a specific type. + * Note: An implementation of the indexer creates only a bootstrap index. + * To keep the index in sync you have to implement a subscriber, see {@link HandlerEventIndexSyncer}. + * + * @param type to index + * @since 2.22.0 + * @see HandlerEventIndexSyncer + */ +@ExtensionPoint +public interface Indexer { + + /** + * Returns class of type. + * + * @return class of type + */ + Class getType(); + + /** + * Returns name of index. + * + * @return name of index + */ + String getIndex(); + + /** + * Returns version of index type. + * The version should be increased if the type changes and should be re indexed. + * + * @return current index type version + */ + int getVersion(); + + /** + * Opens the index and return an updater for the given type. + * + * @return updater with open index + */ + Updater open(); + + /** + * Updater for index. + * + * @param type to index + */ + interface Updater extends AutoCloseable { + + /** + * Stores the given item in the index. + * + * @param item item to index + */ + void store(T item); + + /** + * Delete the given item from the index + * + * @param item item to delete + */ + void delete(T item); + + /** + * Re index all existing items. + */ + void reIndexAll(); + + /** + * Close the given index. + */ + void close(); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java b/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java index c32044f248..69a92b377d 100644 --- a/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java +++ b/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java @@ -26,7 +26,6 @@ package sonia.scm.search; import com.google.common.annotations.Beta; import lombok.Value; -import sonia.scm.ContextEntry; import sonia.scm.NotFoundException; import sonia.scm.repository.Repository; @@ -99,6 +98,20 @@ public abstract class QueryBuilder { return execute(new QueryParams(type, repositoryId, queryString, start, limit)); } + + /** + * Executes the query and returns the total count of hits. + * + * @param type type of objects which are searched + * @param queryString searched query + * + * @return total count of hits + * @since 2.22.0 + */ + public QueryCountResult count(Class type, String queryString) { + return count(new QueryParams(type, repositoryId, queryString, start, limit)); + } + /** * Executes the query and returns the matches. * @@ -109,8 +122,20 @@ public abstract class QueryBuilder { * @throws NotFoundException if type could not be found */ public QueryResult execute(String typeName, String queryString){ - Class type = resolveByName(typeName).orElseThrow(() -> notFound(entity("type", typeName))); - return execute(type, queryString); + return execute(resolveTypeByName(typeName), queryString); + } + + /** + * Executes the query and returns the total count of hits. + * + * @param typeName type name of objects which are searched + * @param queryString searched query + * + * @return total count of hits + * @since 2.22.0 + */ + public QueryCountResult count(String typeName, String queryString) { + return count(resolveTypeByName(typeName), queryString); } /** @@ -128,6 +153,20 @@ public abstract class QueryBuilder { */ protected abstract QueryResult execute(QueryParams queryParams); + /** + * Executes the query and returns the total count of hits. + * @param queryParams query parameter + * @return total hit count + * @since 2.22.0 + */ + protected QueryCountResult count(QueryParams queryParams) { + return execute(queryParams); + } + + private Class resolveTypeByName(String typeName) { + return resolveByName(typeName).orElseThrow(() -> notFound(entity("type", typeName))); + } + /** * The searched query and all parameters, which belong to the query. */ diff --git a/scm-core/src/main/java/sonia/scm/search/QueryCountResult.java b/scm-core/src/main/java/sonia/scm/search/QueryCountResult.java new file mode 100644 index 0000000000..227eda1d90 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/search/QueryCountResult.java @@ -0,0 +1,58 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.search; + +import com.google.common.annotations.Beta; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** + * Result of a counting query. + * + * @since 2.22.0 + * @see QueryBuilder + */ +@Beta +@Getter +@ToString +@EqualsAndHashCode +public class QueryCountResult { + + /** + * Searched type of object. + */ + private final Class type; + + /** + * Total count of hits, which are matched by the query. + */ + private final long totalHits; + + public QueryCountResult(Class type, long totalHits) { + this.type = type; + this.totalHits = totalHits; + } +} diff --git a/scm-core/src/main/java/sonia/scm/search/QueryResult.java b/scm-core/src/main/java/sonia/scm/search/QueryResult.java index 28409ab72b..51ae08b0b4 100644 --- a/scm-core/src/main/java/sonia/scm/search/QueryResult.java +++ b/scm-core/src/main/java/sonia/scm/search/QueryResult.java @@ -25,8 +25,11 @@ package sonia.scm.search; import com.google.common.annotations.Beta; -import lombok.Value; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import java.util.Collections; import java.util.List; /** @@ -34,24 +37,20 @@ import java.util.List; * @since 2.21.0 */ @Beta -@Value -public class QueryResult { - - /** - * Total count of hits, which are matched by the query. - */ - long totalHits; - - /** - * Searched type of object. - */ - Class type; +@Getter +@ToString +@EqualsAndHashCode(callSuper = true) +public class QueryResult extends QueryCountResult { /** * List of hits found by the query. * The list contains only those hits which are starting at start and they are limit by the given amount. * @see QueryBuilder */ - List hits; + private final List hits; + public QueryResult(long totalHits, Class type, List hits) { + super(type, totalHits); + this.hits = Collections.unmodifiableList(hits); + } } diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java index 51af6f7b6d..6cb86a420b 100644 --- a/scm-core/src/main/java/sonia/scm/user/User.java +++ b/scm-core/src/main/java/sonia/scm/user/User.java @@ -35,6 +35,8 @@ import lombok.Setter; import sonia.scm.BasicPropertiesAware; import sonia.scm.ModelObject; import sonia.scm.ReducedModelObject; +import sonia.scm.search.Indexed; +import sonia.scm.search.IndexedType; import sonia.scm.util.Util; import sonia.scm.util.ValidationUtil; @@ -49,6 +51,7 @@ import java.security.Principal; permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"}, custom = true, customGlobal = true ) +@IndexedType(permission = "user:list") @XmlRootElement(name = "users") @XmlAccessorType(XmlAccessType.FIELD) @Getter @@ -61,10 +64,15 @@ public class User extends BasicPropertiesAware implements Principal, ModelObject private boolean active = true; private boolean external; + @Indexed private Long creationDate; + @Indexed(defaultQuery = true) private String displayName; + @Indexed private Long lastModified; + @Indexed private String mail; + @Indexed(defaultQuery = true, boost = 1.5f) private String name; private String password; diff --git a/scm-ui/ui-api/package.json b/scm-ui/ui-api/package.json index ddbe0bdd46..68d7761503 100644 --- a/scm-ui/ui-api/package.json +++ b/scm-ui/ui-api/package.json @@ -19,7 +19,7 @@ "@scm-manager/eslint-config": "^2.10.1", "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/prettier-config": "^2.10.1", - "@scm-manager/tsconfig": "^2.11.2", + "@scm-manager/tsconfig": "^2.12.0", "@testing-library/react-hooks": "^5.0.3", "@types/react": "^17.0.1", "react-test-renderer": "^17.0.1" diff --git a/scm-ui/ui-api/src/search.ts b/scm-ui/ui-api/src/search.ts index 96368a0eb1..d7593c64e6 100644 --- a/scm-ui/ui-api/src/search.ts +++ b/scm-ui/ui-api/src/search.ts @@ -26,7 +26,7 @@ import { ApiResult, useIndexLinks } from "./base"; import { Link, QueryResult } from "@scm-manager/ui-types"; import { apiClient } from "./apiclient"; import { createQueryString } from "./utils"; -import { useQuery } from "react-query"; +import { useQueries, useQuery } from "react-query"; export type SearchOptions = { type: string; @@ -38,7 +38,44 @@ const defaultSearchOptions: SearchOptions = { type: "repository", }; -const useSearchLink = (name: string) => { +const isString = (str: string | undefined): str is string => !!str; + +export const useSearchTypes = () => { + return useSearchLinks() + .map((link) => link.name) + .filter(isString); +}; + +export const useSearchCounts = (types: string[], query: string) => { + const searchLinks = useSearchLinks(); + const queries = useQueries( + types.map((type) => ({ + queryKey: ["search", type, query, "count"], + queryFn: () => + apiClient.get(`${findLink(searchLinks, type)}?q=${query}&countOnly=true`).then((response) => response.json()), + })) + ); + const result: { [type: string]: ApiResult } = {}; + queries.forEach((q, i) => { + result[types[i]] = { + isLoading: q.isLoading, + error: q.error as Error, + data: (q.data as QueryResult)?.totalHits, + }; + }); + return result; +}; + +const findLink = (links: Link[], name: string) => { + for (const l of links) { + if (l.name === name) { + return l.href; + } + } + throw new Error(`could not find search link for ${name}`); +}; + +const useSearchLinks = () => { const links = useIndexLinks(); const searchLinks = links["search"]; if (!searchLinks) { @@ -48,14 +85,12 @@ const useSearchLink = (name: string) => { if (!Array.isArray(searchLinks)) { throw new Error("search links returned in wrong format, array is expected"); } + return searchLinks as Link[]; +}; - for (const l of searchLinks as Link[]) { - if (l.name === name) { - return l.href; - } - } - - throw new Error(`could not find search link for ${name}`); +const useSearchLink = (name: string) => { + const searchLinks = useSearchLinks(); + return findLink(searchLinks, name); }; export const useSearch = (query: string, optionParam = defaultSearchOptions): ApiResult => { @@ -71,7 +106,7 @@ export const useSearch = (query: string, optionParam = defaultSearchOptions): Ap queryParams.pageSize = options.pageSize.toString(); } return useQuery( - ["search", query], + ["search", options.type, queryParams], () => apiClient.get(`${link}?${createQueryString(queryParams)}`).then((response) => response.json()), { enabled: query.length > 1, diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json index bc311435f4..b4a6132694 100644 --- a/scm-ui/ui-components/package.json +++ b/scm-ui/ui-components/package.json @@ -24,7 +24,7 @@ "@scm-manager/eslint-config": "^2.12.0", "@scm-manager/jest-preset": "^2.12.7", "@scm-manager/prettier-config": "^2.10.1", - "@scm-manager/tsconfig": "^2.11.2", + "@scm-manager/tsconfig": "^2.12.0", "@scm-manager/ui-tests": "^2.21.1-SNAPSHOT", "@storybook/addon-actions": "^6.1.17", "@storybook/addon-storyshots": "^6.1.17", diff --git a/scm-ui/ui-components/src/LinkPaginator.tsx b/scm-ui/ui-components/src/LinkPaginator.tsx index 0adbc7554c..01f60eefe6 100644 --- a/scm-ui/ui-components/src/LinkPaginator.tsx +++ b/scm-ui/ui-components/src/LinkPaginator.tsx @@ -21,126 +21,120 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; import { PagedCollection } from "@scm-manager/ui-types"; import { Button } from "./buttons"; -type Props = WithTranslation & { +type Props = { collection: PagedCollection; page: number; filter?: string; }; -class LinkPaginator extends React.Component { - addFilterToLink(link: string) { - const { filter } = this.props; +const LinkPaginator: FC = ({ collection, page, filter }) => { + const [t] = useTranslation("commons"); + const addFilterToLink = (link: string) => { if (filter) { return `${link}?q=${filter}`; } return link; - } + }; - renderFirstButton() { - return + + ); +}; + +const Hits: FC = ({ hits, index, clear, gotoDetailSearch }) => { const id = useCallback(namespaceAndName, [hits]); const [t] = useTranslation("commons"); if (hits.length === 0) { - return ; + return ; } return ( -
+
{t("search.quickSearch.resultHeading")} {hits.map((hit, idx) => (
e.preventDefault()} onClick={clear}> @@ -124,23 +148,26 @@ const Hits: FC = ({ hits, index, clear }) => { })} title={id(hit)} to={`/repo/${id(hit)}`} + role="option" + data-omnisearch="true" > {id(hit)}
))} +
); }; -const useKeyBoardNavigation = (clear: () => void, hits?: Array) => { +const useKeyBoardNavigation = (gotoDetailSearch: () => void, clear: () => void, hits?: Array) => { const [index, setIndex] = useState(-1); const history = useHistory(); useEffect(() => { setIndex(-1); }, [hits]); - const onKeyDown = (e: KeyboardEvent) => { + const onKeyDown = (e: ReactKeyboardEvent) => { // We use e.which, because ie 11 does not support e.code // https://caniuse.com/keyboardevent-code switch (e.which) { @@ -169,10 +196,17 @@ const useKeyBoardNavigation = (clear: () => void, hits?: Array) => { const hit = hits[index]; history.push(`/repo/${namespaceAndName(hit)}`); clear(); + } else { + e.preventDefault(); + gotoDetailSearch(); } break; case 27: // e.code: Escape - clear(); + if (index >= 0) { + setIndex(-1); + } else { + clear(); + } break; } }; @@ -196,26 +230,68 @@ const useDebounce = (value: string, delay: number) => { return debouncedValue; }; +const isMoreResultsButton = (element: Element) => { + return element.tagName.toLocaleLowerCase("en") === "button" && element.className.includes("is-primary"); +}; + +const isOnmiSearchElement = (element: Element) => { + return element.getAttribute("data-omnisearch") || isMoreResultsButton(element); +}; + const useShowResultsOnFocus = () => { const [showResults, setShowResults] = useState(false); + useEffect(() => { + if (showResults) { + const close = () => { + setShowResults(false); + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (e.which === 9) { // tab + const element = document.activeElement; + if (!element || !isOnmiSearchElement(element)) { + close(); + } + } + }; + + window.addEventListener("click", close); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("keyup", onKeyUp); + }; + } + }, [showResults]); return { showResults, - onClick: (e: MouseEvent) => e.stopPropagation(), + onClick: (e: MouseEvent) => { + e.stopPropagation(); + setShowResults(true); + }, + onKeyPress: () => setShowResults(true), onFocus: () => setShowResults(true), - onBlur: () => setShowResults(false), + hideResults: () => setShowResults(false), }; }; const OmniSearch: FC = () => { const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, 250); - const { data, isLoading, error } = useSearch(debouncedQuery, { pageSize: 5 }); - const { showResults, ...handlers } = useShowResultsOnFocus(); + const { data, isLoading, error } = useSearch(debouncedQuery, { type: "repository", pageSize: 5 }); + const { showResults, hideResults, ...handlers } = useShowResultsOnFocus(); + const history = useHistory(); const clearQuery = () => { setQuery(""); }; - const { onKeyDown, index } = useKeyBoardNavigation(clearQuery, data?._embedded.hits); + + const gotoDetailSearch = () => { + history.push(`/search/repository/?q=${query}`); + hideResults(); + }; + + const { onKeyDown, index } = useKeyBoardNavigation(gotoDetailSearch, clearQuery, data?._embedded.hits); return ( @@ -233,6 +309,9 @@ const OmniSearch: FC = () => { onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown} value={query} + role="combobox" + aria-autocomplete="list" + data-omnisearch="true" {...handlers} /> {isLoading ? null : ( @@ -241,9 +320,11 @@ const OmniSearch: FC = () => { )}
- + e.preventDefault()}> {error ? : null} - {!error && data ? : null} + {!error && data ? ( + + ) : null} diff --git a/scm-ui/ui-webapp/src/search/GenericHit.tsx b/scm-ui/ui-webapp/src/search/GenericHit.tsx new file mode 100644 index 0000000000..bf3f4a8377 --- /dev/null +++ b/scm-ui/ui-webapp/src/search/GenericHit.tsx @@ -0,0 +1,50 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { Hit, HitProps, TextHitField } from "@scm-manager/ui-components"; +import styled from "styled-components"; + +const LabelColumn = styled.th` + min-width: 10rem; +`; + +const GenericHit: FC = ({ hit }) => ( + + + + {Object.keys(hit.fields).map((field) => ( + + {field}: + + + ))} +
+ +
+
+
+); + +export default GenericHit; diff --git a/scm-ui/ui-webapp/src/search/GroupHit.tsx b/scm-ui/ui-webapp/src/search/GroupHit.tsx new file mode 100644 index 0000000000..d962d4614e --- /dev/null +++ b/scm-ui/ui-webapp/src/search/GroupHit.tsx @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { + Hit, + HitProps, + DateFromNow, + TextHitField, + useDateHitFieldValue, + useStringHitFieldValue, +} from "@scm-manager/ui-components"; +import { Link } from "react-router-dom"; + +const GroupHit: FC = ({ hit }) => { + const name = useStringHitFieldValue(hit, "name"); + const lastModified = useDateHitFieldValue(hit, "lastModified"); + const creationDate = useDateHitFieldValue(hit, "creationDate"); + const date = lastModified || creationDate; + + return ( + + + + + + + +

+ +

+
+ + + +
+ ); +}; + +export default GroupHit; diff --git a/scm-ui/ui-webapp/src/search/Hits.tsx b/scm-ui/ui-webapp/src/search/Hits.tsx new file mode 100644 index 0000000000..958b27835f --- /dev/null +++ b/scm-ui/ui-webapp/src/search/Hits.tsx @@ -0,0 +1,85 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { Hit as HitType } from "@scm-manager/ui-types"; +import RepositoryHit from "./RepositoryHit"; +import GenericHit from "./GenericHit"; +import UserHit from "./UserHit"; +import GroupHit from "./GroupHit"; +import { Notification, HitProps } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; + +type Props = { + type: string; + hits: HitType[]; +}; + +const hitComponents: { [name: string]: FC } = { + repository: RepositoryHit, + user: UserHit, + group: GroupHit, +}; + +const findComponent = (type: string) => { + const cmp = hitComponents[type]; + if (!cmp) { + return GenericHit; + } + return cmp; +}; + +type HitComponentProps = { + type: string; + hit: HitType; +}; + +const InternalHitRenderer: FC = ({ type, hit }) => { + const Cmp = findComponent(type); + return ; +}; + +const HitComponent: FC = ({ hit, type }) => ( + + + +); + +const NoHits: FC = () => { + const [t] = useTranslation("commons"); + return {t("search.noHits")}; +}; + +const Hits: FC = ({ type, hits }) => ( +
+ {hits.length > 0 ? ( + hits.map((hit, c) => ) + ) : ( + + )} +
+); + +export default Hits; diff --git a/scm-ui/ui-webapp/src/search/RepositoryHit.tsx b/scm-ui/ui-webapp/src/search/RepositoryHit.tsx new file mode 100644 index 0000000000..322d620c64 --- /dev/null +++ b/scm-ui/ui-webapp/src/search/RepositoryHit.tsx @@ -0,0 +1,82 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { Repository } from "@scm-manager/ui-types"; +import { Link } from "react-router-dom"; +import { + useDateHitFieldValue, + useStringHitFieldValue, + DateFromNow, + RepositoryAvatar, + TextHitField, + Hit, + HitProps, +} from "@scm-manager/ui-components"; + +const RepositoryHit: FC = ({ hit }) => { + const namespace = useStringHitFieldValue(hit, "namespace"); + const name = useStringHitFieldValue(hit, "name"); + const type = useStringHitFieldValue(hit, "type"); + const lastModified = useDateHitFieldValue(hit, "lastModified"); + const creationDate = useDateHitFieldValue(hit, "creationDate"); + const date = lastModified || creationDate; + + if (!namespace || !name || !type) { + return null; + } + + const repository: Repository = { + namespace, + name, + type, + _links: {}, + _embedded: hit._embedded, + }; + + return ( + + + + + + + + + + {namespace}/{name} + + +

+ +

+
+ + + +
+ ); +}; + +export default RepositoryHit; diff --git a/scm-ui/ui-webapp/src/search/Results.tsx b/scm-ui/ui-webapp/src/search/Results.tsx new file mode 100644 index 0000000000..d40839b050 --- /dev/null +++ b/scm-ui/ui-webapp/src/search/Results.tsx @@ -0,0 +1,48 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { QueryResult } from "@scm-manager/ui-types"; +import Hits from "./Hits"; +import { LinkPaginator } from "@scm-manager/ui-components"; + +type Props = { + result: QueryResult; + type: string; + page: number; + query: string; +}; + +const Results: FC = ({ result, type, page, query }) => { + return ( +
+ +
+ +
+
+ ); +}; + +export default Results; diff --git a/scm-ui/ui-webapp/src/search/Search.tsx b/scm-ui/ui-webapp/src/search/Search.tsx new file mode 100644 index 0000000000..613e495ec9 --- /dev/null +++ b/scm-ui/ui-webapp/src/search/Search.tsx @@ -0,0 +1,149 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { + CustomQueryFlexWrappedColumns, + Level, + NavLink, + Page, + PrimaryContentColumn, + SecondaryNavigation, + Tag, + urls, +} from "@scm-manager/ui-components"; +import { useLocation, useParams } from "react-router-dom"; +import { useSearch, useSearchCounts, useSearchTypes } from "@scm-manager/ui-api"; +import Results from "./Results"; +import { useTranslation } from "react-i18next"; + +type PathParams = { + type: string; + page: string; +}; + +type CountProps = { + isLoading: boolean; + isSelected: boolean; + count?: number; +}; + +const Count: FC = ({ isLoading, isSelected, count }) => { + if (isLoading) { + return ; + } + return ( + + {count} + + ); +}; + +const usePageParams = () => { + const location = useLocation(); + const { type: selectedType, ...params } = useParams(); + const page = urls.getPageFromMatch({ params }); + const query = urls.getQueryStringFromLocation(location); + return { + page, + selectedType, + query, + }; +}; + +const orderTypes = (left: string, right: string) => { + if (left === "repository" && right !== "repository") { + return -1; + } else if (left !== "repository" && right === "repository") { + return 1; + } else if (left < right) { + return -1; + } else if (left > right) { + return 1; + } + return 0; +}; + +const Search: FC = () => { + const [t] = useTranslation(["commons", "plugins"]); + const { query, selectedType, page } = usePageParams(); + const { data, isLoading, error } = useSearch(query, { + type: selectedType, + page: page - 1, + pageSize: 25, + }); + const types = useSearchTypes(); + types.sort(orderTypes); + + const searchCounts = useSearchCounts( + types.filter((t) => t !== selectedType), + query + ); + const counts = { + [selectedType]: { + isLoading, + error, + data: data?.totalHits, + }, + ...searchCounts, + }; + + return ( + + {data ? ( + + + + + + {types.map((type) => ( + + + } + /> + + ))} + + + ) : null} + + ); +}; + +export default Search; diff --git a/scm-ui/ui-webapp/src/search/UserHit.tsx b/scm-ui/ui-webapp/src/search/UserHit.tsx new file mode 100644 index 0000000000..865af47dcc --- /dev/null +++ b/scm-ui/ui-webapp/src/search/UserHit.tsx @@ -0,0 +1,63 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { Link } from "react-router-dom"; +import { + DateFromNow, + useDateHitFieldValue, + useStringHitFieldValue, + TextHitField, + Hit, + HitProps, +} from "@scm-manager/ui-components"; + +const UserHit: FC = ({ hit }) => { + const name = useStringHitFieldValue(hit, "name"); + const lastModified = useDateHitFieldValue(hit, "lastModified"); + const creationDate = useDateHitFieldValue(hit, "creationDate"); + const date = lastModified || creationDate; + + return ( + + + + + + + +

+ < + + > +

+
+ + + +
+ ); +}; + +export default UserHit; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultDto.java index f99865d9b5..6026a768b0 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/QueryResultDto.java @@ -35,6 +35,7 @@ import lombok.Setter; public class QueryResultDto extends CollectionDto { private Class type; + private long totalHits; QueryResultDto(Links links, Embedded embedded) { super(links, embedded); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java index 5b38a34d61..d615c65516 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java @@ -61,6 +61,9 @@ public class SearchParameters { @PathParam("type") private String type; + @QueryParam("countOnly") + private boolean countOnly = false; + String getSelfLink() { return uriInfo.getAbsolutePath().toASCIIString(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java index 6b338ecbfc..29f8432198 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java @@ -32,6 +32,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import sonia.scm.search.IndexNames; +import sonia.scm.search.QueryCountResult; import sonia.scm.search.QueryResult; import sonia.scm.search.SearchEngine; import sonia.scm.web.VndMediaType; @@ -42,6 +43,7 @@ import javax.ws.rs.BeanParam; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; +import java.util.Collections; @Path(SearchResource.PATH) @OpenAPIDefinition(tags = { @@ -98,7 +100,18 @@ public class SearchResource { name = "pageSize", description = "The maximum number of results per page (defaults to 10)" ) + @Parameter( + name = "countOnly", + description = "If set to 'true', no results will be returned, only the count of hits and the page count" + ) public QueryResultDto query(@Valid @BeanParam SearchParameters params) { + if (params.isCountOnly()) { + return count(params); + } + return search(params); + } + + private QueryResultDto search(SearchParameters params) { QueryResult result = engine.search(IndexNames.DEFAULT) .start(params.getPage() * params.getPageSize()) .limit(params.getPageSize()) @@ -107,4 +120,11 @@ public class SearchResource { return mapper.map(params, result); } + private QueryResultDto count(SearchParameters params) { + QueryCountResult result = engine.search(IndexNames.DEFAULT) + .count(params.getType(), params.getQuery()); + + return mapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList())); + } + } diff --git a/scm-webapp/src/main/java/sonia/scm/group/GroupIndexer.java b/scm-webapp/src/main/java/sonia/scm/group/GroupIndexer.java new file mode 100644 index 0000000000..2f4eacc421 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/group/GroupIndexer.java @@ -0,0 +1,116 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.group; + +import com.github.legman.Subscribe; +import com.google.common.annotations.VisibleForTesting; +import sonia.scm.plugin.Extension; +import sonia.scm.search.HandlerEventIndexSyncer; +import sonia.scm.search.Id; +import sonia.scm.search.Index; +import sonia.scm.search.IndexNames; +import sonia.scm.search.IndexQueue; +import sonia.scm.search.Indexer; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Extension +@Singleton +public class GroupIndexer implements Indexer { + + @VisibleForTesting + static final String INDEX = IndexNames.DEFAULT; + @VisibleForTesting + static final int VERSION = 1; + + private final GroupManager groupManager; + private final IndexQueue indexQueue; + + @Inject + public GroupIndexer(GroupManager groupManager, IndexQueue indexQueue) { + this.groupManager = groupManager; + this.indexQueue = indexQueue; + } + + @Override + public Class getType() { + return Group.class; + } + + @Override + public String getIndex() { + return INDEX; + } + + @Override + public int getVersion() { + return VERSION; + } + + @Subscribe(async = false) + public void handleEvent(GroupEvent event) { + new HandlerEventIndexSyncer<>(this).handleEvent(event); + } + + @Override + public Updater open() { + return new GroupIndexUpdater(groupManager, indexQueue.getQueuedIndex(INDEX)); + } + + public static class GroupIndexUpdater implements Updater { + + private final GroupManager groupManager; + private final Index index; + + private GroupIndexUpdater(GroupManager groupManager, Index index) { + this.groupManager = groupManager; + this.index = index; + } + + @Override + public void store(Group group) { + index.store(Id.of(group), GroupPermissions.read(group).asShiroString(), group); + } + + @Override + public void delete(Group group) { + index.delete(Id.of(group), Group.class); + } + + @Override + public void reIndexAll() { + index.deleteByType(Group.class); + for (Group group : groupManager.getAll()) { + store(group); + } + } + + @Override + public void close() { + index.close(); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/IndexUpdateListener.java b/scm-webapp/src/main/java/sonia/scm/repository/IndexUpdateListener.java deleted file mode 100644 index 34cb1a542c..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/repository/IndexUpdateListener.java +++ /dev/null @@ -1,140 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2020-present Cloudogu GmbH and Contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package sonia.scm.repository; - -import com.github.legman.Subscribe; -import com.google.common.annotations.VisibleForTesting; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.HandlerEventType; -import sonia.scm.plugin.Extension; -import sonia.scm.search.Id; -import sonia.scm.search.Index; -import sonia.scm.search.IndexLog; -import sonia.scm.search.IndexLogStore; -import sonia.scm.search.IndexNames; -import sonia.scm.search.IndexQueue; -import sonia.scm.web.security.AdministrationContext; -import sonia.scm.web.security.PrivilegedAction; - -import javax.inject.Inject; -import javax.inject.Singleton; -import javax.servlet.ServletContextEvent; -import javax.servlet.ServletContextListener; -import java.util.Optional; - -@Extension -@Singleton -public class IndexUpdateListener implements ServletContextListener { - - private static final Logger LOG = LoggerFactory.getLogger(IndexUpdateListener.class); - - @VisibleForTesting - static final int INDEX_VERSION = 2; - - private final AdministrationContext administrationContext; - private final IndexQueue queue; - private final IndexLogStore indexLogStore; - - @Inject - public IndexUpdateListener(AdministrationContext administrationContext, IndexQueue queue, IndexLogStore indexLogStore) { - this.administrationContext = administrationContext; - this.queue = queue; - this.indexLogStore = indexLogStore; - } - - @Subscribe(async = false) - public void handleEvent(RepositoryEvent event) { - HandlerEventType type = event.getEventType(); - if (type.isPost()) { - updateIndex(type, event.getItem()); - } - } - - - private void updateIndex(HandlerEventType type, Repository repository) { - try (Index index = queue.getQueuedIndex(IndexNames.DEFAULT)) { - if (type == HandlerEventType.DELETE) { - index.deleteByRepository(repository.getId()); - } else { - store(index, repository); - } - } - } - - @Override - public void contextInitialized(ServletContextEvent servletContextEvent) { - Optional indexLog = indexLogStore.get(IndexNames.DEFAULT, Repository.class); - if (indexLog.isPresent()) { - int version = indexLog.get().getVersion(); - if (version < INDEX_VERSION) { - LOG.debug("repository index {} is older then {}, start reindexing of all repositories", version, INDEX_VERSION); - indexAll(); - } - } else { - LOG.debug("could not find log entry for repository index, start reindexing of all repositories"); - indexAll(); - } - } - - private void indexAll() { - administrationContext.runAsAdmin(ReIndexAll.class); - indexLogStore.log(IndexNames.DEFAULT, Repository.class, INDEX_VERSION); - } - - @Override - public void contextDestroyed(ServletContextEvent servletContextEvent) { - // we have nothing to destroy - } - - private static void store(Index index, Repository repository) { - index.store(Id.of(repository), RepositoryPermissions.read(repository).asShiroString(), repository); - } - - static class ReIndexAll implements PrivilegedAction { - - private final RepositoryManager repositoryManager; - private final IndexQueue queue; - - @Inject - public ReIndexAll(RepositoryManager repositoryManager, IndexQueue queue) { - this.repositoryManager = repositoryManager; - this.queue = queue; - } - - @Override - public void run() { - try (Index index = queue.getQueuedIndex(IndexNames.DEFAULT)) { - // delete v1 types - index.deleteByTypeName(Repository.class.getName()); - for (Repository repository : repositoryManager.getAll()) { - store(index, repository); - } - } - } - - } - -} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/RepositoryIndexer.java b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryIndexer.java new file mode 100644 index 0000000000..151831dfb0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryIndexer.java @@ -0,0 +1,119 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import com.github.legman.Subscribe; +import com.google.common.annotations.VisibleForTesting; +import sonia.scm.plugin.Extension; +import sonia.scm.search.HandlerEventIndexSyncer; +import sonia.scm.search.Id; +import sonia.scm.search.Index; +import sonia.scm.search.IndexNames; +import sonia.scm.search.IndexQueue; +import sonia.scm.search.Indexer; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +@Extension +public class RepositoryIndexer implements Indexer { + + @VisibleForTesting + static final int VERSION = 2; + + @VisibleForTesting + static final String INDEX = IndexNames.DEFAULT; + + private final RepositoryManager repositoryManager; + private final IndexQueue indexQueue; + + @Inject + public RepositoryIndexer(RepositoryManager repositoryManager, IndexQueue indexQueue) { + this.repositoryManager = repositoryManager; + this.indexQueue = indexQueue; + } + + @Override + public int getVersion() { + return VERSION; + } + + @Override + public Class getType() { + return Repository.class; + } + + @Override + public String getIndex() { + return INDEX; + } + + @Subscribe(async = false) + public void handleEvent(RepositoryEvent event) { + new HandlerEventIndexSyncer<>(this).handleEvent(event); + } + + @Override + public Updater open() { + return new RepositoryIndexUpdater(repositoryManager, indexQueue.getQueuedIndex(INDEX)); + } + + public static class RepositoryIndexUpdater implements Updater { + + private final RepositoryManager repositoryManager; + private final Index index; + + public RepositoryIndexUpdater(RepositoryManager repositoryManager, Index index) { + this.repositoryManager = repositoryManager; + this.index = index; + } + + @Override + public void store(Repository repository) { + index.store(Id.of(repository), RepositoryPermissions.read(repository).asShiroString(), repository); + } + + @Override + public void delete(Repository repository) { + index.deleteByRepository(repository.getId()); + } + + @Override + public void reIndexAll() { + // v1 used the whole classname as type + index.deleteByTypeName(Repository.class.getName()); + index.deleteByType(Repository.class); + for (Repository repository : repositoryManager.getAll()) { + store(repository); + } + } + + @Override + public void close() { + index.close(); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/search/IndexBootstrapListener.java b/scm-webapp/src/main/java/sonia/scm/search/IndexBootstrapListener.java new file mode 100644 index 0000000000..52102bb51e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/search/IndexBootstrapListener.java @@ -0,0 +1,92 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.search; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.plugin.Extension; +import sonia.scm.web.security.AdministrationContext; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import java.util.Optional; +import java.util.Set; + +@Singleton +@Extension +@SuppressWarnings("rawtypes") +public class IndexBootstrapListener implements ServletContextListener { + + private static final Logger LOG = LoggerFactory.getLogger(IndexBootstrapListener.class); + + private final AdministrationContext administrationContext; + private final IndexLogStore indexLogStore; + private final Set indexers; + + @Inject + public IndexBootstrapListener(AdministrationContext administrationContext, IndexLogStore indexLogStore, Set indexers) { + this.administrationContext = administrationContext; + this.indexLogStore = indexLogStore; + this.indexers = indexers; + } + + @Override + public void contextInitialized(ServletContextEvent servletContextEvent) { + for (Indexer indexer : indexers) { + bootstrap(indexer); + } + } + + private void bootstrap(Indexer indexer) { + Optional indexLog = indexLogStore.get(indexer.getIndex(), indexer.getType()); + if (indexLog.isPresent()) { + int version = indexLog.get().getVersion(); + if (version < indexer.getVersion()) { + LOG.debug("index version {} is older then {}, start reindexing of all {}", version, indexer.getVersion(), indexer.getType()); + indexAll(indexer); + } + } else { + LOG.debug("could not find log entry for {} index, start reindexing", indexer.getType()); + indexAll(indexer); + } + } + + private void indexAll(Indexer indexer) { + administrationContext.runAsAdmin(() -> { + try (Indexer.Updater updater = indexer.open()) { + updater.reIndexAll(); + } + }); + + indexLogStore.log(indexer.getIndex(), indexer.getType(), indexer.getVersion()); + } + + @Override + public void contextDestroyed(ServletContextEvent servletContextEvent) { + // nothing to destroy here + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java b/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java index 41a8bfde7c..101a409181 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java +++ b/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java @@ -38,6 +38,7 @@ import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.Query; import org.apache.lucene.search.TopDocs; import org.apache.lucene.search.TopScoreDocCollector; +import org.apache.lucene.search.TotalHitCountCollector; import org.apache.lucene.search.WildcardQuery; import org.apache.lucene.search.highlight.InvalidTokenOffsetsException; import org.apache.shiro.SecurityUtils; @@ -70,8 +71,25 @@ public class LuceneQueryBuilder extends QueryBuilder { return resolver.resolveClassByName(typeName); } + @Override + protected QueryCountResult count(QueryParams queryParams) { + TotalHitCountCollector totalHitCountCollector = new TotalHitCountCollector(); + return search( + queryParams, totalHitCountCollector, + (searcher, type, query) -> new QueryCountResult(type.getType(), totalHitCountCollector.getTotalHits()) + ); + } + @Override protected QueryResult execute(QueryParams queryParams) { + TopScoreDocCollector topScoreCollector = createTopScoreCollector(queryParams); + return search(queryParams, topScoreCollector, (searcher, searchableType, query) -> { + QueryResultFactory resultFactory = new QueryResultFactory(analyzer, searcher, searchableType, query); + return resultFactory.create(getTopDocs(queryParams, topScoreCollector)); + }); + } + + private T search(QueryParams queryParams, Collector collector, ResultBuilder resultBuilder) { String queryString = Strings.nullToEmpty(queryParams.getQueryString()); LuceneSearchableType searchableType = resolver.resolve(queryParams.getType()); @@ -87,12 +105,9 @@ public class LuceneQueryBuilder extends QueryBuilder { try (IndexReader reader = opener.openForRead(indexName)) { IndexSearcher searcher = new IndexSearcher(reader); - TopScoreDocCollector topScoreCollector = createTopScoreCollector(queryParams); - Collector collector = new PermissionAwareCollector(reader, topScoreCollector); - searcher.search(query, collector); + searcher.search(query, new PermissionAwareCollector(reader, collector)); - QueryResultFactory resultFactory = new QueryResultFactory(analyzer, searcher, searchableType, query); - return resultFactory.create(getTopDocs(queryParams, topScoreCollector)); + return resultBuilder.create(searcher, searchableType, parsedQuery); } catch (IOException e) { throw new SearchEngineException("failed to search index", e); } catch (InvalidTokenOffsetsException e) { @@ -155,4 +170,9 @@ public class LuceneQueryBuilder extends QueryBuilder { } return queryString; } + + @FunctionalInterface + private interface ResultBuilder { + T create(IndexSearcher searcher, LuceneSearchableType searchableType, Query query) throws IOException, InvalidTokenOffsetsException; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java b/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java index 5444195967..197b21812f 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java @@ -82,6 +82,7 @@ public class QueryResultFactory { } return new Hit(document.get(FieldNames.ID), scoreDoc.score, fields); } + private Optional field(Document document, SearchableField field) throws IOException, InvalidTokenOffsetsException { Object value = field.value(document); if (value != null) { diff --git a/scm-webapp/src/main/java/sonia/scm/user/UserIndexer.java b/scm-webapp/src/main/java/sonia/scm/user/UserIndexer.java new file mode 100644 index 0000000000..5f67c1d93d --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/user/UserIndexer.java @@ -0,0 +1,116 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.user; + +import com.github.legman.Subscribe; +import com.google.common.annotations.VisibleForTesting; +import sonia.scm.plugin.Extension; +import sonia.scm.search.HandlerEventIndexSyncer; +import sonia.scm.search.Id; +import sonia.scm.search.Index; +import sonia.scm.search.IndexNames; +import sonia.scm.search.IndexQueue; +import sonia.scm.search.Indexer; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Extension +@Singleton +public class UserIndexer implements Indexer { + + @VisibleForTesting + static final String INDEX = IndexNames.DEFAULT; + @VisibleForTesting + static final int VERSION = 1; + + private final UserManager userManager; + private final IndexQueue queue; + + @Inject + public UserIndexer(UserManager userManager, IndexQueue queue) { + this.userManager = userManager; + this.queue = queue; + } + + @Override + public Class getType() { + return User.class; + } + + @Override + public String getIndex() { + return INDEX; + } + + @Override + public int getVersion() { + return VERSION; + } + + @Subscribe(async = false) + public void handleEvent(UserEvent event) { + new HandlerEventIndexSyncer<>(this).handleEvent(event); + } + + @Override + public Updater open() { + return new UserIndexUpdater(userManager, queue.getQueuedIndex(INDEX)); + } + + public static class UserIndexUpdater implements Updater { + + private final UserManager userManager; + private final Index index; + + private UserIndexUpdater(UserManager userManager, Index index) { + this.userManager = userManager; + this.index = index; + } + + @Override + public void store(User user) { + index.store(Id.of(user), UserPermissions.read(user).asShiroString(), user); + } + + @Override + public void delete(User user) { + index.delete(Id.of(user), User.class); + } + + @Override + public void reIndexAll() { + index.deleteByType(User.class); + for (User user : userManager.getAll()) { + store(user); + } + } + + @Override + public void close() { + index.close(); + } + } +} diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index c87946f02a..9e4c3a3ae0 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -433,5 +433,21 @@ "exportFinished": "Der Repository Export wurde abgeschlossen.", "exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator.", "healthCheckFailed": "Der Repository Health Check ist fehlgeschlagen." + }, + "search": { + "types": { + "repository": { + "subtitle": "Repository", + "navItem": "Repositories" + }, + "user": { + "subtitle": "Benutzer", + "navItem": "Benutzer" + }, + "group": { + "subtitle": "Gruppe", + "navItem": "Gruppen" + } + } } } diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index d4b697067c..9726404c01 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -377,5 +377,21 @@ "exportFinished": "The repository export has been finished.", "exportFailed": "The repository export has failed. Try it again or contact your administrator.", "healthCheckFailed": "The repository health check has failed." + }, + "search": { + "types": { + "repository": { + "subtitle": "Repository", + "navItem": "Repositories" + }, + "user": { + "subtitle": "User", + "navItem": "Users" + }, + "group": { + "subtitle": "Group", + "navItem": "Groups" + } + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java index 45b072c174..b00abcef66 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java @@ -39,6 +39,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.search.Hit; import sonia.scm.search.IndexNames; +import sonia.scm.search.QueryCountResult; import sonia.scm.search.QueryResult; import sonia.scm.search.SearchEngine; import sonia.scm.web.JsonMockHttpResponse; @@ -182,6 +183,25 @@ class SearchResourceTest { assertThat(highlightedField.get("fragments").get(0).asText()).isEqualTo("Hello"); } + @Test + void shouldReturnCountOnly() throws URISyntaxException { + when( + searchEngine.search(IndexNames.DEFAULT) + .count("string", "Hello") + ).thenReturn(new QueryCountResult(String.class, 2L)); + + MockHttpRequest request = MockHttpRequest.get("/v2/search/query/string?q=Hello&countOnly=true"); + JsonMockHttpResponse response = new JsonMockHttpResponse(); + dispatcher.invoke(request, response); + + JsonNode node = response.getContentAsJson(); + + assertThat(node.get("totalHits").asLong()).isEqualTo(2L); + + JsonNode hits = node.get("_embedded").get("hits"); + assertThat(hits.size()).isZero(); + } + } private void assertLink(JsonNode links, String self, String s) { @@ -245,6 +265,7 @@ class SearchResourceTest { return response; } + @Getter @Setter public static class SampleEmbedded extends HalRepresentation { diff --git a/scm-webapp/src/test/java/sonia/scm/group/GroupIndexerTest.java b/scm-webapp/src/test/java/sonia/scm/group/GroupIndexerTest.java new file mode 100644 index 0000000000..8c6f3cae15 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/group/GroupIndexerTest.java @@ -0,0 +1,125 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.group; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +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; +import sonia.scm.HandlerEventType; +import sonia.scm.search.Id; +import sonia.scm.search.Index; +import sonia.scm.search.IndexQueue; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GroupIndexerTest { + + @Mock + private GroupManager groupManager; + + @Mock + private IndexQueue indexQueue; + + @InjectMocks + private GroupIndexer indexer; + + @Test + void shouldReturnRepositoryClass() { + assertThat(indexer.getType()).isEqualTo(Group.class); + } + + @Test + void shouldReturnIndexName() { + assertThat(indexer.getIndex()).isEqualTo(GroupIndexer.INDEX); + } + + @Test + void shouldReturnVersion() { + assertThat(indexer.getVersion()).isEqualTo(GroupIndexer.VERSION); + } + + @Nested + class UpdaterTests { + + @Mock + private Index index; + + private final Group group = new Group("xml", "astronauts"); + + @BeforeEach + void open() { + when(indexQueue.getQueuedIndex(GroupIndexer.INDEX)).thenReturn(index); + } + + @Test + void shouldStoreRepository() { + indexer.open().store(group); + + verify(index).store(Id.of(group), "group:read:astronauts", group); + } + + @Test + void shouldDeleteByRepository() { + indexer.open().delete(group); + + verify(index).delete(Id.of(group), Group.class); + } + + @Test + void shouldReIndexAll() { + when(groupManager.getAll()).thenReturn(singletonList(group)); + + indexer.open().reIndexAll(); + + verify(index).deleteByType(Group.class); + verify(index).store(Id.of(group), "group:read:astronauts", group); + } + + @Test + void shouldHandleEvent() { + GroupEvent event = new GroupEvent(HandlerEventType.DELETE, group); + + indexer.handleEvent(event); + + verify(index).delete(Id.of(group), Group.class); + } + + @Test + void shouldCloseIndex() { + indexer.open().close(); + + verify(index).close(); + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/IndexUpdateListenerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/IndexUpdateListenerTest.java deleted file mode 100644 index 13a2bb02b7..0000000000 --- a/scm-webapp/src/test/java/sonia/scm/repository/IndexUpdateListenerTest.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2020-present Cloudogu GmbH and Contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package sonia.scm.repository; - -import com.google.common.collect.ImmutableList; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import sonia.scm.HandlerEventType; -import sonia.scm.search.Id; -import sonia.scm.search.Index; -import sonia.scm.search.IndexLog; -import sonia.scm.search.IndexLogStore; -import sonia.scm.search.IndexNames; -import sonia.scm.search.IndexQueue; -import sonia.scm.web.security.AdministrationContext; - -import java.util.List; -import java.util.Optional; - -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class IndexUpdateListenerTest { - - @Mock - private RepositoryManager repositoryManager; - - @Mock - private AdministrationContext administrationContext; - - @Mock - private IndexQueue indexQueue; - - @Mock - private Index index; - - @Mock - private IndexLogStore indexLogStore; - - @InjectMocks - private IndexUpdateListener updateListener; - - @Test - @SuppressWarnings("java:S6068") - void shouldIndexAllRepositories() { - when(indexLogStore.get(IndexNames.DEFAULT, Repository.class)).thenReturn(Optional.empty()); - doAnswer(ic -> { - IndexUpdateListener.ReIndexAll reIndexAll = new IndexUpdateListener.ReIndexAll(repositoryManager, indexQueue); - reIndexAll.run(); - return null; - }) - .when(administrationContext) - .runAsAdmin(IndexUpdateListener.ReIndexAll.class); - - Repository heartOfGold = RepositoryTestData.createHeartOfGold(); - Repository puzzle42 = RepositoryTestData.create42Puzzle(); - List repositories = ImmutableList.of(heartOfGold, puzzle42); - - when(repositoryManager.getAll()).thenReturn(repositories); - when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index); - - updateListener.contextInitialized(null); - - verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold); - verify(index).store(Id.of(puzzle42), RepositoryPermissions.read(puzzle42).asShiroString(), puzzle42); - verify(index).close(); - - verify(indexLogStore).log(IndexNames.DEFAULT, Repository.class, IndexUpdateListener.INDEX_VERSION); - } - - @Test - void shouldSkipReIndex() { - IndexLog log = new IndexLog(1); - when(indexLogStore.get(IndexNames.DEFAULT, Repository.class)).thenReturn(Optional.of(log)); - - updateListener.contextInitialized(null); - - verifyNoInteractions(indexQueue); - } - - @ParameterizedTest - @EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = "BEFORE_.*") - void shouldIgnoreBeforeEvents(HandlerEventType type) { - RepositoryEvent event = new RepositoryEvent(type, RepositoryTestData.create42Puzzle()); - - updateListener.handleEvent(event); - - verifyNoInteractions(indexQueue); - } - - @ParameterizedTest - @EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.INCLUDE, names = {"CREATE", "MODIFY"}) - void shouldStore(HandlerEventType type) { - when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index); - - Repository puzzle = RepositoryTestData.create42Puzzle(); - RepositoryEvent event = new RepositoryEvent(type, puzzle); - - updateListener.handleEvent(event); - - verify(index).store(Id.of(puzzle), RepositoryPermissions.read(puzzle).asShiroString(), puzzle); - verify(index).close(); - } - - @Test - void shouldDelete() { - when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index); - Repository puzzle = RepositoryTestData.create42Puzzle(); - RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, puzzle); - - updateListener.handleEvent(event); - - verify(index).deleteByRepository(puzzle.getId()); - verify(index).close(); - } - -} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/RepositoryIndexerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryIndexerTest.java new file mode 100644 index 0000000000..166f0be4e2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryIndexerTest.java @@ -0,0 +1,128 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +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; +import sonia.scm.HandlerEventType; +import sonia.scm.search.Id; +import sonia.scm.search.Index; +import sonia.scm.search.IndexQueue; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RepositoryIndexerTest { + + @Mock + private RepositoryManager repositoryManager; + + @Mock + private IndexQueue indexQueue; + + @InjectMocks + private RepositoryIndexer indexer; + + @Test + void shouldReturnRepositoryClass() { + assertThat(indexer.getType()).isEqualTo(Repository.class); + } + + @Test + void shouldReturnIndexName() { + assertThat(indexer.getIndex()).isEqualTo(RepositoryIndexer.INDEX); + } + + @Test + void shouldReturnVersion() { + assertThat(indexer.getVersion()).isEqualTo(RepositoryIndexer.VERSION); + } + + @Nested + class UpdaterTests { + + @Mock + private Index index; + + private Repository repository; + + @BeforeEach + void open() { + when(indexQueue.getQueuedIndex(RepositoryIndexer.INDEX)).thenReturn(index); + repository = new Repository(); + repository.setId("42"); + } + + @Test + void shouldStoreRepository() { + indexer.open().store(repository); + + verify(index).store(Id.of(repository), "repository:read:42", repository); + } + + @Test + void shouldDeleteByRepository() { + indexer.open().delete(repository); + + verify(index).deleteByRepository("42"); + } + + @Test + void shouldReIndexAll() { + when(repositoryManager.getAll()).thenReturn(singletonList(repository)); + + indexer.open().reIndexAll(); + + verify(index).deleteByTypeName(Repository.class.getName()); + verify(index).deleteByType(Repository.class); + + verify(index).store(Id.of(repository), "repository:read:42", repository); + } + + @Test + void shouldHandleEvent() { + RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, repository); + + indexer.handleEvent(event); + + verify(index).deleteByRepository("42"); + } + + @Test + void shouldCloseIndex() { + indexer.open().close(); + + verify(index).close(); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/search/HandlerEventIndexSyncerTest.java b/scm-webapp/src/test/java/sonia/scm/search/HandlerEventIndexSyncerTest.java new file mode 100644 index 0000000000..360788bf22 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/search/HandlerEventIndexSyncerTest.java @@ -0,0 +1,88 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.search; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.HandlerEventType; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryEvent; +import sonia.scm.repository.RepositoryTestData; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HandlerEventIndexSyncerTest { + + @Mock + private Indexer indexer; + + @Mock + private Indexer.Updater updater; + + @ParameterizedTest + @EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = "BEFORE_.*") + void shouldIgnoreBeforeEvents(HandlerEventType type) { + RepositoryEvent event = new RepositoryEvent(type, RepositoryTestData.create42Puzzle()); + + new HandlerEventIndexSyncer<>(indexer).handleEvent(event); + + verifyNoInteractions(indexer); + } + + @ParameterizedTest + @EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.INCLUDE, names = {"CREATE", "MODIFY"}) + void shouldStore(HandlerEventType type) { + when(indexer.open()).thenReturn(updater); + + Repository puzzle = RepositoryTestData.create42Puzzle(); + RepositoryEvent event = new RepositoryEvent(type, puzzle); + + new HandlerEventIndexSyncer<>(indexer).handleEvent(event); + + verify(updater).store(puzzle); + verify(updater).close(); + } + + @Test + void shouldDelete() { + when(indexer.open()).thenReturn(updater); + + Repository puzzle = RepositoryTestData.create42Puzzle(); + RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, puzzle); + + new HandlerEventIndexSyncer<>(indexer).handleEvent(event); + + verify(updater).delete(puzzle); + verify(updater).close(); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/search/IndexBootstrapListenerTest.java b/scm-webapp/src/test/java/sonia/scm/search/IndexBootstrapListenerTest.java new file mode 100644 index 0000000000..33538ac606 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/search/IndexBootstrapListenerTest.java @@ -0,0 +1,145 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.search; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.group.Group; +import sonia.scm.repository.Repository; +import sonia.scm.user.User; +import sonia.scm.web.security.AdministrationContext; +import sonia.scm.web.security.PrivilegedAction; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class IndexBootstrapListenerTest { + + @Mock + private AdministrationContext administrationContext; + + @Mock + private IndexLogStore indexLogStore; + + @Test + void shouldReIndexWithoutLog() { + mockAdminContext(); + Indexer indexer = indexer(Repository.class, 1); + Indexer.Updater updater = updater(indexer); + + mockEmptyIndexLog(Repository.class); + doInitialization(indexer); + + verify(updater).reIndexAll(); + verify(updater).close(); + verify(indexLogStore).log(IndexNames.DEFAULT, Repository.class, 1); + } + + @Test + void shouldReIndexIfVersionWasUpdated() { + mockAdminContext(); + Indexer indexer = indexer(User.class, 2); + Indexer.Updater updater = updater(indexer); + + mockIndexLog(User.class, 1); + doInitialization(indexer); + + verify(updater).reIndexAll(); + verify(updater).close(); + verify(indexLogStore).log(IndexNames.DEFAULT, User.class, 2); + } + + @Test + void shouldSkipReIndexIfVersionIsEqual() { + Indexer indexer = indexer(Group.class, 3); + + mockIndexLog(Group.class, 3); + doInitialization(indexer); + + verify(indexer, never()).open(); + } + + private void mockIndexLog(Class type, int version) { + mockIndexLog(type, new IndexLog(version)); + } + + private void mockEmptyIndexLog(Class type) { + mockIndexLog(type, null); + } + + private void mockIndexLog(Class type, @Nullable IndexLog indexLog) { + when(indexLogStore.get(IndexNames.DEFAULT, type)).thenReturn(Optional.ofNullable(indexLog)); + } + + private void mockAdminContext() { + doAnswer(ic -> { + PrivilegedAction action = ic.getArgument(0); + action.run(); + return null; + }).when(administrationContext).runAsAdmin(any(PrivilegedAction.class)); + } + + @SuppressWarnings("rawtypes") + private void doInitialization(Indexer... indexers) { + IndexBootstrapListener listener = listener(indexers); + listener.contextInitialized(null); + } + + @Nonnull + @SuppressWarnings("rawtypes") + private IndexBootstrapListener listener(Indexer... indexers) { + return new IndexBootstrapListener( + administrationContext, indexLogStore, new HashSet<>(Arrays.asList(indexers)) + ); + } + + private Indexer indexer(Class type, int version) { + Indexer indexer = mock(Indexer.class); + when(indexer.getType()).thenReturn(type); + when(indexer.getVersion()).thenReturn(version); + when(indexer.getIndex()).thenReturn(IndexNames.DEFAULT); + return indexer; + } + + private Indexer.Updater updater(Indexer indexer) { + Indexer.Updater updater = mock(Indexer.Updater.class); + when(indexer.open()).thenReturn(updater); + return updater; + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java index 36103a4c29..8348a9230c 100644 --- a/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java @@ -87,6 +87,16 @@ class LuceneQueryBuilderTest { assertThat(result.getTotalHits()).isOne(); } + @Test + void shouldReturnCount() throws IOException { + try (IndexWriter writer = writer()) { + writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211")); + } + + long hits = count(InetOrgPerson.class, "Arthur"); + assertThat(hits).isOne(); + } + @Test void shouldMatchPartial() throws IOException { try (IndexWriter writer = writer()) { @@ -239,6 +249,18 @@ class LuceneQueryBuilderTest { }); } + @Test + void shouldCountOnlyPermittedHits() throws IOException { + try (IndexWriter writer = writer()) { + writer.addDocument(permissionDoc("Awesome content one", "abc")); + writer.addDocument(permissionDoc("Awesome content two", "cde")); + writer.addDocument(permissionDoc("Awesome content three", "fgh")); + } + + long hits = count(Simple.class, "content:awesome"); + assertThat(hits).isOne(); + } + @Test void shouldFilterByRepository() throws IOException { try (IndexWriter writer = writer()) { @@ -499,6 +521,17 @@ class LuceneQueryBuilderTest { return query(type, queryString, null, null); } + private long count(Class type, String queryString) throws IOException { + try (DirectoryReader reader = DirectoryReader.open(directory)) { + lenient().when(opener.openForRead("default")).thenReturn(reader); + SearchableTypeResolver resolver = new SearchableTypeResolver(type); + LuceneQueryBuilder builder = new LuceneQueryBuilder( + opener, resolver, "default", new StandardAnalyzer() + ); + return builder.count(type, queryString).getTotalHits(); + } + } + private QueryResult query(Class type, String queryString, Integer start, Integer limit) throws IOException { try (DirectoryReader reader = DirectoryReader.open(directory)) { lenient().when(opener.openForRead("default")).thenReturn(reader); diff --git a/scm-webapp/src/test/java/sonia/scm/user/UserIndexerTest.java b/scm-webapp/src/test/java/sonia/scm/user/UserIndexerTest.java new file mode 100644 index 0000000000..eee9ebaefa --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/user/UserIndexerTest.java @@ -0,0 +1,125 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.user; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +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; +import sonia.scm.HandlerEventType; +import sonia.scm.search.Id; +import sonia.scm.search.Index; +import sonia.scm.search.IndexQueue; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserIndexerTest { + + @Mock + private UserManager userManager; + + @Mock + private IndexQueue indexQueue; + + @InjectMocks + private UserIndexer indexer; + + @Test + void shouldReturnRepositoryClass() { + assertThat(indexer.getType()).isEqualTo(User.class); + } + + @Test + void shouldReturnIndexName() { + assertThat(indexer.getIndex()).isEqualTo(UserIndexer.INDEX); + } + + @Test + void shouldReturnVersion() { + assertThat(indexer.getVersion()).isEqualTo(UserIndexer.VERSION); + } + + @Nested + class UpdaterTests { + + @Mock + private Index index; + + private final User user = UserTestData.createTrillian(); + + @BeforeEach + void open() { + when(indexQueue.getQueuedIndex(UserIndexer.INDEX)).thenReturn(index); + } + + @Test + void shouldStoreRepository() { + indexer.open().store(user); + + verify(index).store(Id.of(user), "user:read:trillian", user); + } + + @Test + void shouldDeleteByRepository() { + indexer.open().delete(user); + + verify(index).delete(Id.of(user), User.class); + } + + @Test + void shouldReIndexAll() { + when(userManager.getAll()).thenReturn(singletonList(user)); + + indexer.open().reIndexAll(); + + verify(index).deleteByType(User.class); + verify(index).store(Id.of(user), "user:read:trillian", user); + } + + @Test + void shouldHandleEvent() { + UserEvent event = new UserEvent(HandlerEventType.DELETE, user); + + indexer.handleEvent(event); + + verify(index).delete(Id.of(user), User.class); + } + + @Test + void shouldCloseIndex() { + indexer.open().close(); + + verify(index).close(); + } + } + +} diff --git a/yarn.lock b/yarn.lock index 7b2b1fa503..f5fe40ad8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2995,12 +2995,12 @@ unist-util-generated "^1.1.6" unist-util-visit "^2.0.3" -"@scm-manager/tsconfig@^2.11.2": - version "2.11.2" - resolved "https://registry.yarnpkg.com/@scm-manager/tsconfig/-/tsconfig-2.11.2.tgz#7f0ed5fd49ca77a9ecf34066507779535f2270bd" - integrity sha512-Y69XFe6EZFg/ZMt2g90uBEsxL3IPaAUYmra0bYHF2/dkmlJeZWJ0+0i3TrHMMajokXxTSpXwJUTrjC8bBWEYIw== +"@scm-manager/tsconfig@^2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@scm-manager/tsconfig/-/tsconfig-2.12.0.tgz#ccbf149b4fccd7ef7a7cc9c1c4c7f19bec4d78e1" + integrity sha512-vcT1/Tn3MaNdj5vssQX7pBOfLJ9/RWVF3Ut1sVpcM2dsuQEo/qaXFlfLsVZxeQr1bJ45731l0hB/z55GjXTLKw== dependencies: - typescript "^4.1.3" + typescript "^4.3.5" "@sinonjs/commons@^1.7.0": version "1.8.3" @@ -19393,11 +19393,16 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^4.0.5, typescript@^4.1.3: +typescript@^4.0.5: version "4.2.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== +typescript@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" + integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== + uglify-js@^3.1.4: version "3.13.6" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.6.tgz#6815ac7fdd155d03c83e2362bb717e5b39b74013" @@ -20035,8 +20040,10 @@ watchpack@^1.7.4: resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.5.tgz#1267e6c55e0b9b5be44c2023aed5437a2c26c453" integrity sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ== dependencies: + chokidar "^3.4.1" graceful-fs "^4.1.2" neo-async "^2.5.0" + watchpack-chokidar2 "^2.0.1" optionalDependencies: chokidar "^3.4.1" watchpack-chokidar2 "^2.0.1"