One index per type and parallel indexing (#1781)

Before this change the search uses a single index which distinguishes types (repositories, users, etc.) with a field (_type).
But it has turned out that this could lead to problems, in particular if different types have the same field and uses different analyzers for those fields. The following links show even more problems of a combined index:

    https://www.elastic.co/blog/index-vs-type
    https://www.elastic.co/guide/en/elasticsearch/reference/6.0/removal-of-types.html

With this change every type becomes its own index and the SearchEngine gets an api to modify multiple indices at once to remove all documents from all indices, which are related to a specific repository, for example.

The search uses another new api to coordinate the indexing, the central work queue.
The central work queue is able to coordinate long-running or resource intensive tasks. It is able to run tasks in parallel, but can also run tasks which targets the same resources in sequence. The queue is also persistent and can restore queued tasks after restart.

Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
Sebastian Sdorra
2021-08-25 15:40:11 +02:00
committed by GitHub
parent 44f25d6b15
commit 0a26741ebd
72 changed files with 4536 additions and 1420 deletions

View File

@@ -24,20 +24,23 @@
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.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
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.IndexLogStore;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import java.util.Arrays;
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;
@@ -54,6 +57,19 @@ class GroupIndexerTest {
@InjectMocks
private GroupIndexer indexer;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Group> index;
@Mock
private IndexLogStore indexLogStore;
@Captor
private ArgumentCaptor<SerializableIndexTask<Group>> captor;
private final Group astronauts = new Group("xml", "astronauts");
private final Group planetCreators = new Group("xml", "planet-creators");
@Test
void shouldReturnClass() {
assertThat(indexer.getType()).isEqualTo(Group.class);
@@ -64,58 +80,47 @@ class GroupIndexerTest {
assertThat(indexer.getVersion()).isEqualTo(GroupIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Group> index;
@Test
void shouldReturnReIndexAllClass() {
assertThat(indexer.getReIndexAllTask()).isEqualTo(GroupIndexer.ReIndexAll.class);
}
private final Group group = new Group("xml", "astronauts");
@Test
void shouldCreateGroup() {
indexer.createStoreTask(astronauts).update(index);
@BeforeEach
void open() {
when(searchEngine.forType(Group.class).getOrCreate()).thenReturn(index);
}
verify(index).store(Id.of(astronauts), GroupPermissions.read(astronauts).asShiroString(), astronauts);
}
@Test
void shouldStore() {
indexer.open().store(group);
@Test
void shouldDeleteGroup() {
indexer.createDeleteTask(astronauts).update(index);
verify(index).store(Id.of(group), "group:read:astronauts", group);
}
verify(index.delete()).byId(Id.of(astronauts));
}
@Test
void shouldDeleteById() {
indexer.open().delete(group);
@Test
void shouldReIndexAll() {
when(groupManager.getAll()).thenReturn(Arrays.asList(astronauts, planetCreators));
verify(index.delete().byType()).byId(Id.of(group));
}
GroupIndexer.ReIndexAll reIndexAll = new GroupIndexer.ReIndexAll(indexLogStore, groupManager);
reIndexAll.update(index);
@Test
void shouldReIndexAll() {
when(groupManager.getAll()).thenReturn(singletonList(group));
verify(index.delete()).all();
verify(index).store(Id.of(astronauts), GroupPermissions.read(astronauts).asShiroString(), astronauts);
verify(index).store(Id.of(planetCreators), GroupPermissions.read(planetCreators).asShiroString(), planetCreators);
}
indexer.open().reIndexAll();
@Test
void shouldHandleEvents() {
GroupEvent event = new GroupEvent(HandlerEventType.DELETE, astronauts);
verify(index.delete().byType()).all();
verify(index).store(Id.of(group), "group:read:astronauts", group);
}
indexer.handleEvent(event);
@Test
void shouldHandleEvent() {
GroupEvent event = new GroupEvent(HandlerEventType.DELETE, group);
indexer.handleEvent(event);
verify(index.delete().byType()).byId(Id.of(group));
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
verify(searchEngine.forType(Group.class)).update(captor.capture());
captor.getValue().update(index);
verify(index.delete()).byId(Id.of(astronauts));
}
}

View File

@@ -24,20 +24,25 @@
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
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.IndexLogStore;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import java.util.Arrays;
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;
@@ -54,6 +59,15 @@ class RepositoryIndexerTest {
@InjectMocks
private RepositoryIndexer indexer;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Repository> index;
@Mock
private IndexLogStore indexLogStore;
@Captor
private ArgumentCaptor<SerializableIndexTask<Repository>> captor;
@Test
void shouldReturnRepositoryClass() {
assertThat(indexer.getType()).isEqualTo(Repository.class);
@@ -64,61 +78,65 @@ class RepositoryIndexerTest {
assertThat(indexer.getVersion()).isEqualTo(RepositoryIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Repository> index;
private Repository repository;
@BeforeEach
void open() {
when(searchEngine.forType(Repository.class).getOrCreate()).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.delete().allTypes()).byRepository("42");
}
@Test
void shouldReIndexAll() {
when(repositoryManager.getAll()).thenReturn(singletonList(repository));
indexer.open().reIndexAll();
verify(index.delete().allTypes()).byTypeName(Repository.class.getName());
verify(index.delete().byType()).all();
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.delete().allTypes()).byRepository("42");
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
@Test
void shouldReturnReIndexAllClass() {
assertThat(indexer.getReIndexAllTask()).isEqualTo(RepositoryIndexer.ReIndexAll.class);
}
@Test
void shouldCreateRepository() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
indexer.createStoreTask(heartOfGold).update(index);
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
}
@Test
void shouldDeleteRepository() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
indexer.createDeleteTask(heartOfGold).update(index);
verify(index.delete()).byRepository(heartOfGold);
}
@Test
void shouldReIndexAll() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
Repository puzzle = RepositoryTestData.create42Puzzle();
when(repositoryManager.getAll()).thenReturn(Arrays.asList(heartOfGold, puzzle));
RepositoryIndexer.ReIndexAll reIndexAll = new RepositoryIndexer.ReIndexAll(indexLogStore, repositoryManager);
reIndexAll.update(index);
verify(index.delete()).all();
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
verify(index).store(Id.of(puzzle), RepositoryPermissions.read(puzzle).asShiroString(), puzzle);
}
@Test
void shouldHandleDeleteEvents() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, heartOfGold);
indexer.handleEvent(event);
verify(searchEngine.forIndices().forResource(heartOfGold)).batch(captor.capture());
captor.getValue().update(index);
verify(index.delete()).byRepository(heartOfGold);
}
@Test
void shouldHandleUpdateEvents() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
RepositoryEvent event = new RepositoryEvent(HandlerEventType.CREATE, heartOfGold);
indexer.handleEvent(event);
verify(searchEngine.forType(Repository.class)).update(captor.capture());
captor.getValue().update(index);
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
}
}

View File

@@ -24,10 +24,12 @@
package sonia.scm.search;
import org.junit.jupiter.api.BeforeEach;
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.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
@@ -35,6 +37,7 @@ import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryEvent;
import sonia.scm.repository.RepositoryTestData;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -42,18 +45,23 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HandlerEventIndexSyncerTest {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private SearchEngine searchEngine;
@Mock
private Indexer<Repository> indexer;
@Mock
private Indexer.Updater<Repository> updater;
@BeforeEach
void setUpIndexer() {
lenient().when(indexer.getType()).thenReturn(Repository.class);
}
@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);
new HandlerEventIndexSyncer<>(searchEngine, indexer).handleEvent(event);
verifyNoInteractions(indexer);
}
@@ -61,28 +69,29 @@ class HandlerEventIndexSyncerTest {
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.INCLUDE, names = {"CREATE", "MODIFY"})
void shouldStore(HandlerEventType type) {
when(indexer.open()).thenReturn(updater);
SerializableIndexTask<Repository> store = index -> {};
Repository puzzle = RepositoryTestData.create42Puzzle();
when(indexer.createStoreTask(puzzle)).thenReturn(store);
RepositoryEvent event = new RepositoryEvent(type, puzzle);
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
new HandlerEventIndexSyncer<>(searchEngine, indexer).handleEvent(event);
verify(updater).store(puzzle);
verify(updater).close();
verify(searchEngine.forType(Repository.class)).update(store);
}
@Test
void shouldDelete() {
when(indexer.open()).thenReturn(updater);
SerializableIndexTask<Repository> delete = index -> {};
Repository puzzle = RepositoryTestData.create42Puzzle();
when(indexer.createDeleteTask(puzzle)).thenReturn(delete);
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, puzzle);
new HandlerEventIndexSyncer<>(searchEngine, indexer).handleEvent(event);
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
verify(updater).delete(puzzle);
verify(updater).close();
verify(searchEngine.forType(Repository.class)).update(delete);
}
}

View File

@@ -32,8 +32,6 @@ 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;
@@ -42,47 +40,39 @@ 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.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexBootstrapListenerTest {
@Mock
private AdministrationContext administrationContext;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private SearchEngine searchEngine;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private IndexLogStore indexLogStore;
@Test
void shouldReIndexWithoutLog() {
mockAdminContext();
Indexer<Repository> indexer = indexer(Repository.class, 1);
Indexer.Updater<Repository> updater = updater(indexer);
mockEmptyIndexLog(Repository.class);
doInitialization(indexer);
verify(updater).reIndexAll();
verify(updater).close();
verify(indexLogStore.defaultIndex()).log(Repository.class, 1);
verify(searchEngine.forType(Repository.class)).update(RepositoryReIndexAllTask.class);
}
@Test
void shouldReIndexIfVersionWasUpdated() {
mockAdminContext();
Indexer<User> indexer = indexer(User.class, 2);
Indexer.Updater<User> updater = updater(indexer);
mockIndexLog(User.class, 1);
doInitialization(indexer);
verify(updater).reIndexAll();
verify(updater).close();
verify(indexLogStore.defaultIndex()).log(User.class, 2);
verify(searchEngine.forType(User.class)).update(UserReIndexAllTask.class);
}
@Test
@@ -92,7 +82,7 @@ class IndexBootstrapListenerTest {
mockIndexLog(Group.class, 3);
doInitialization(indexer);
verify(indexer, never()).open();
verifyNoInteractions(searchEngine);
}
private <T> void mockIndexLog(Class<T> type, int version) {
@@ -107,13 +97,6 @@ class IndexBootstrapListenerTest {
when(indexLogStore.defaultIndex().get(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) {
@@ -125,7 +108,7 @@ class IndexBootstrapListenerTest {
@SuppressWarnings("rawtypes")
private IndexBootstrapListener listener(Indexer... indexers) {
return new IndexBootstrapListener(
administrationContext, indexLogStore, new HashSet<>(Arrays.asList(indexers))
searchEngine, indexLogStore, new HashSet<>(Arrays.asList(indexers))
);
}
@@ -133,15 +116,38 @@ class IndexBootstrapListenerTest {
private <T> Indexer<T> indexer(Class<T> type, int version) {
Indexer<T> indexer = mock(Indexer.class);
when(indexer.getType()).thenReturn(type);
when(indexer.getVersion()).thenReturn(version);
lenient().when(indexer.getVersion()).thenReturn(version);
lenient().when(indexer.getReIndexAllTask()).thenAnswer(ic -> {
if (type == User.class) {
return UserReIndexAllTask.class;
}
return RepositoryReIndexAllTask.class;
});
return indexer;
}
@SuppressWarnings("unchecked")
private <T> Indexer.Updater<T> updater(Indexer<T> indexer) {
Indexer.Updater<T> updater = mock(Indexer.Updater.class);
when(indexer.open()).thenReturn(updater);
return updater;
public static class RepositoryReIndexAllTask extends Indexer.ReIndexAllTask<Repository> {
public RepositoryReIndexAllTask(IndexLogStore logStore, Class<Repository> type, int version) {
super(logStore, type, version);
}
@Override
public void update(Index<Repository> index) {
}
}
public static class UserReIndexAllTask extends Indexer.ReIndexAllTask<User> {
public UserReIndexAllTask(IndexLogStore logStore, Class<User> type, int version) {
super(logStore, type, version);
}
@Override
public void update(Index<User> index) {
}
}
}

View File

@@ -0,0 +1,188 @@
/*
* 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.apache.lucene.analysis.core.SimpleAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.assertj.core.api.AssertionsForClassTypes;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import sonia.scm.plugin.PluginLoader;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexManagerTest {
private Path directory;
@Mock
private AnalyzerFactory analyzerFactory;
@Mock
private LuceneSearchableType searchableType;
@Mock
private SCMContextProvider context;
@Mock
private PluginLoader pluginLoader;
private IndexManager indexManager;
@BeforeEach
void createIndexWriterFactory(@TempDir Path tempDirectory) {
this.directory = tempDirectory;
when(context.resolve(Paths.get("index"))).thenReturn(tempDirectory.resolve("index"));
when(analyzerFactory.create(any(LuceneSearchableType.class), any(IndexOptions.class))).thenReturn(new SimpleAnalyzer());
when(pluginLoader.getUberClassLoader()).thenReturn(IndexManagerTest.class.getClassLoader());
indexManager = new IndexManager(context, pluginLoader, analyzerFactory);
}
@Test
void shouldCreateNewIndex() throws IOException {
try (IndexWriter writer = open(Songs.class, "new-index")) {
addDoc(writer, "Trillian");
}
assertThat(directory.resolve("index").resolve("songs").resolve("new-index")).exists();
}
@Test
void shouldCreateNewIndexForEachType() throws IOException {
try (IndexWriter writer = open(Songs.class, "new-index")) {
addDoc(writer, "Trillian");
}
try (IndexWriter writer = open(Lyrics.class, "new-index")) {
addDoc(writer, "Trillian");
}
assertThat(directory.resolve("index").resolve("songs").resolve("new-index")).exists();
assertThat(directory.resolve("index").resolve("lyrics").resolve("new-index")).exists();
}
@Test
void shouldReturnAllCreatedIndices() throws IOException {
try (IndexWriter writer = open(Songs.class, "special")) {
addDoc(writer, "Trillian");
}
try (IndexWriter writer = open(Lyrics.class, "awesome")) {
addDoc(writer, "Trillian");
}
assertThat(indexManager.all())
.anySatisfy(details -> {
assertThat(details.getType()).isEqualTo(Songs.class);
assertThat(details.getName()).isEqualTo("special");
})
.anySatisfy(details -> {
assertThat(details.getType()).isEqualTo(Lyrics.class);
assertThat(details.getName()).isEqualTo("awesome");
});
}
@Test
void shouldRestoreIndicesOnCreation() throws IOException {
try (IndexWriter writer = open(Songs.class, "special")) {
addDoc(writer, "Trillian");
}
try (IndexWriter writer = open(Lyrics.class, "awesome")) {
addDoc(writer, "Trillian");
}
assertThat(new IndexManager(context, pluginLoader, analyzerFactory).all())
.anySatisfy(details -> {
AssertionsForClassTypes.assertThat(details.getType()).isEqualTo(Songs.class);
AssertionsForClassTypes.assertThat(details.getName()).isEqualTo("special");
})
.anySatisfy(details -> {
AssertionsForClassTypes.assertThat(details.getType()).isEqualTo(Lyrics.class);
AssertionsForClassTypes.assertThat(details.getName()).isEqualTo("awesome");
});
}
@SuppressWarnings({"rawtypes", "unchecked"})
private IndexWriter open(Class type, String indexName) throws IOException {
lenient().when(searchableType.getType()).thenReturn(type);
when(searchableType.getName()).thenReturn(type.getSimpleName().toLowerCase(Locale.ENGLISH));
return indexManager.openForWrite(new IndexParams(indexName, searchableType, IndexOptions.defaults()));
}
@Test
void shouldOpenExistingIndex() throws IOException {
try (IndexWriter writer = open(Songs.class, "reused")) {
addDoc(writer, "Dent");
}
try (IndexWriter writer = open(Songs.class, "reused")) {
assertThat(writer.getFieldNames()).contains("hitchhiker");
}
}
@Test
void shouldUseAnalyzerFromFactory() throws IOException {
try (IndexWriter writer = open(Songs.class, "new-index")) {
assertThat(writer.getAnalyzer()).isInstanceOf(SimpleAnalyzer.class);
}
}
@Test
void shouldOpenIndexForRead() throws IOException {
try (IndexWriter writer = open(Songs.class, "idx-for-read")) {
addDoc(writer, "Dent");
}
try (IndexReader reader = indexManager.openForRead(searchableType, "idx-for-read")) {
assertThat(reader.numDocs()).isOne();
}
}
private void addDoc(IndexWriter writer, String name) throws IOException {
Document doc = new Document();
doc.add(new TextField("hitchhiker", name, Field.Store.YES));
writer.addDocument(doc);
}
public static class Songs {
}
public static class Lyrics {
}
}

View File

@@ -1,118 +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.search;
import org.apache.lucene.analysis.core.SimpleAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexOpenerTest {
private Path directory;
@Mock
private AnalyzerFactory analyzerFactory;
@Mock
private LuceneSearchableType searchableType;
private IndexOpener indexOpener;
@BeforeEach
void createIndexWriterFactory(@TempDir Path tempDirectory) {
this.directory = tempDirectory;
SCMContextProvider context = mock(SCMContextProvider.class);
when(context.resolve(Paths.get("index"))).thenReturn(tempDirectory);
when(analyzerFactory.create(any(LuceneSearchableType.class), any(IndexOptions.class))).thenReturn(new SimpleAnalyzer());
indexOpener = new IndexOpener(context, analyzerFactory);
}
@Test
void shouldCreateNewIndex() throws IOException {
try (IndexWriter writer = open("new-index")) {
addDoc(writer, "Trillian");
}
assertThat(directory.resolve("new-index")).exists();
}
private IndexWriter open(String index) throws IOException {
return indexOpener.openForWrite(new IndexParams(index, searchableType, IndexOptions.defaults()));
}
@Test
void shouldOpenExistingIndex() throws IOException {
try (IndexWriter writer = open("reused")) {
addDoc(writer, "Dent");
}
try (IndexWriter writer = open("reused")) {
assertThat(writer.getFieldNames()).contains("hitchhiker");
}
}
@Test
void shouldUseAnalyzerFromFactory() throws IOException {
try (IndexWriter writer = open("new-index")) {
assertThat(writer.getAnalyzer()).isInstanceOf(SimpleAnalyzer.class);
}
}
@Test
void shouldOpenIndexForRead() throws IOException {
try (IndexWriter writer = open("idx-for-read")) {
addDoc(writer, "Dent");
}
try (IndexReader reader = indexOpener.openForRead("idx-for-read")) {
assertThat(reader.numDocs()).isOne();
}
}
private void addDoc(IndexWriter writer, String name) throws IOException {
Document doc = new Document();
doc.add(new TextField("hitchhiker", name, Field.Store.YES));
writer.addDocument(doc);
}
}

View File

@@ -1,153 +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.search;
import lombok.Value;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.in;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexQueueTest {
private Directory directory;
private IndexQueue queue;
@BeforeEach
void createQueue() throws IOException {
directory = new ByteBuffersDirectory();
IndexOpener opener = mock(IndexOpener.class);
when(opener.openForWrite(any(IndexParams.class))).thenAnswer(ic -> {
IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer());
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
return new IndexWriter(directory, config);
});
LuceneIndexFactory indexFactory = new LuceneIndexFactory(opener);
queue = new IndexQueue(indexFactory);
}
@AfterEach
void closeQueue() throws IOException {
queue.close();
directory.close();
}
@Test
void shouldWriteToIndex() throws Exception {
try (Index<Account> index = getIndex(Account.class)) {
index.store(Id.of("tricia"), null, new Account("tricia", "Trillian", "McMillan"));
index.store(Id.of("dent"), null, new Account("dent", "Arthur", "Dent"));
}
assertDocCount(2);
}
private <T> Index<T> getIndex(Class<T> type) {
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
LuceneSearchableType searchableType = resolver.resolve(type);
IndexParams indexParams = new IndexParams("default", searchableType, IndexOptions.defaults());
return queue.getQueuedIndex(indexParams);
}
@Test
void shouldWriteMultiThreaded() throws Exception {
ExecutorService executorService = Executors.newFixedThreadPool(4);
for (int i = 0; i < 20; i++) {
executorService.execute(new IndexNumberTask(i));
}
executorService.execute(() -> {
try (Index<IndexedNumber> index = getIndex(IndexedNumber.class)) {
index.delete().byType().byId(Id.of(String.valueOf(12)));
}
});
executorService.shutdown();
assertDocCount(19);
}
private void assertDocCount(int expectedCount) throws IOException {
// wait until all tasks are finished
await().until(() -> queue.getSize() == 0);
try (DirectoryReader reader = DirectoryReader.open(directory)) {
assertThat(reader.numDocs()).isEqualTo(expectedCount);
}
}
@Value
@IndexedType
public static class Account {
@Indexed
String username;
@Indexed
String firstName;
@Indexed
String lastName;
}
@Value
@IndexedType
public static class IndexedNumber {
@Indexed
int value;
}
public class IndexNumberTask implements Runnable {
private final int number;
public IndexNumberTask(int number) {
this.number = number;
}
@Override
public void run() {
try (Index<IndexedNumber> index = getIndex(IndexedNumber.class)) {
index.store(Id.of(String.valueOf(number)), null, new IndexedNumber(number));
}
}
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.user.User;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class LuceneIndexFactoryTest {
@Mock
private IndexManager indexManager;
@InjectMocks
private LuceneIndexFactory indexFactory;
@Test
void shouldCallOpenOnCreation() {
LuceneIndex<Repository> index = indexFactory.create(params("default", Repository.class));
assertThat(index.getWriter().getUsageCounter()).isOne();
}
@Test
void shouldCallOpenOnReturn() {
indexFactory.create(params("default", Repository.class));
indexFactory.create(params("default", Repository.class));
LuceneIndex<Repository> index = indexFactory.create(params("default", Repository.class));
assertThat(index.getWriter().getUsageCounter()).isEqualTo(3);
}
@Test
@SuppressWarnings("AssertBetweenInconvertibleTypes")
void shouldReturnDifferentIndexForDifferentTypes() {
LuceneIndex<Repository> repository = indexFactory.create(params("default", Repository.class));
LuceneIndex<User> user = indexFactory.create(params("default", User.class));
assertThat(repository).isNotSameAs(user);
}
@Test
void shouldReturnDifferentIndexForDifferentIndexNames() {
LuceneIndex<Repository> def = indexFactory.create(params("default", Repository.class));
LuceneIndex<Repository> other = indexFactory.create(params("other", Repository.class));
assertThat(def).isNotSameAs(other);
}
@Test
void shouldReturnSameIndex() {
LuceneIndex<Repository> one = indexFactory.create(params("default", Repository.class));
LuceneIndex<Repository> two = indexFactory.create(params("default", Repository.class));
assertThat(one).isSameAs(two);
}
private IndexParams params(String indexName, Class<?> type) {
return new IndexParams(indexName, SearchableTypes.create(type), IndexOptions.defaults());
}
}

View File

@@ -27,7 +27,6 @@ package sonia.scm.search;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import lombok.Value;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
@@ -39,12 +38,26 @@ import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.RepositoryType;
import java.io.IOException;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.search.FieldNames.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static sonia.scm.search.FieldNames.ID;
import static sonia.scm.search.FieldNames.PERMISSION;
import static sonia.scm.search.FieldNames.REPOSITORY;
class LuceneIndexTest {
@@ -77,15 +90,6 @@ class LuceneIndexTest {
assertHits("value", "content", 1);
}
@Test
void shouldStoreUidOfObject() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Awesome content which should be indexed"));
}
assertHits(UID, "one/storable", 1);
}
@Test
void shouldStoreIdOfObject() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
@@ -104,15 +108,6 @@ class LuceneIndexTest {
assertHits(REPOSITORY, "4211", 1);
}
@Test
void shouldStoreTypeOfObject() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Some other text"));
}
assertHits(TYPE, "storable", 1);
}
@Test
void shouldDeleteById() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
@@ -120,131 +115,54 @@ class LuceneIndexTest {
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byId(ONE);
index.delete().byId(ONE);
}
assertHits(ID, "one", 0);
}
@Test
void shouldDeleteAllByType() throws IOException {
void shouldDeleteByIdAndRepository() throws IOException {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
Repository puzzle42 = RepositoryTestData.createHeartOfGold();
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("content"));
index.store(Id.of("two"), null, new Storable("content"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(Id.of("three"), null, new OtherStorable("content"));
index.store(ONE.withRepository(heartOfGold), null, new Storable("content"));
index.store(ONE.withRepository(puzzle42), null, new Storable("content"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().all();
index.delete().byId(ONE.withRepository(heartOfGold));
}
assertHits("value", "content", 1);
}
@Test
void shouldDeleteByIdAnyType() throws IOException {
void shouldDeleteAll() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(ONE, null, new OtherStorable("Some other text"));
index.store(ONE, null, new Storable("content"));
index.store(TWO, null, new Storable("content"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byId(ONE);
index.delete().all();
}
assertHits(ID, "one", 1);
ScoreDoc[] docs = assertHits(ID, "one", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get("value")).isEqualTo("Some other text");
}
@Test
void shouldDeleteByIdAndRepository() throws IOException {
Id withRepository = ONE.withRepository("4211");
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Some other text"));
index.store(withRepository, null, new Storable("New stuff"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byId(withRepository);
}
ScoreDoc[] docs = assertHits(ID, "one", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get("value")).isEqualTo("Some other text");
assertHits("value", "content", 0);
}
@Test
void shouldDeleteByRepository() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE.withRepository("4211"), null, new Storable("Some other text"));
index.store(ONE.withRepository("4212"), null, new Storable("New stuff"));
index.store(ONE.withRepository("4211"), null, new Storable("content"));
index.store(TWO.withRepository("4212"), null, new Storable("content"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byRepository("4212");
index.delete().byRepository("4212");
}
assertHits(ID, "one", 1);
}
@Test
void shouldDeleteByRepositoryAndType() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE.withRepository("4211"), null, new Storable("some text"));
index.store(TWO.withRepository("4211"), null, new Storable("some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(ONE.withRepository("4211"), null, new OtherStorable("some text"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byRepository("4211");
}
ScoreDoc[] docs = assertHits("value", "text", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get(TYPE)).isEqualTo("otherStorable");
}
@Test
void shouldDeleteAllByRepository() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE.withRepository("4211"), null, new Storable("some text"));
index.store(TWO.withRepository("4211"), null, new Storable("some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(ONE.withRepository("4211"), null, new OtherStorable("some text"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().allTypes().byRepository("4211");
}
assertHits("value", "text", 0);
}
@Test
void shouldDeleteAllByTypeName() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("some text"));
index.store(TWO, null, new Storable("some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.delete().allTypes().byTypeName("storable");
}
assertHits("value", "text", 0);
assertHits("value", "content", 1);
}
@Test
@@ -256,12 +174,69 @@ class LuceneIndexTest {
assertHits(PERMISSION, "repo:4211:read", 1);
}
private Document doc(int doc) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
return reader.document(doc);
@Test
void shouldReturnDetails() {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
IndexDetails details = index.getDetails();
assertThat(details.getType()).isEqualTo(Storable.class);
assertThat(details.getName()).isEqualTo("default");
}
}
@Nested
@ExtendWith(MockitoExtension.class)
class ExceptionTests {
@Mock
private IndexWriter writer;
private LuceneIndex<Storable> index;
@BeforeEach
void setUpIndex() {
index = createIndex(Storable.class, () -> writer);
}
@Test
void shouldThrowSearchEngineExceptionOnStore() throws IOException {
when(writer.updateDocument(any(), any())).thenThrow(new IOException("failed to store"));
Storable storable = new Storable("Some other text");
assertThrows(SearchEngineException.class, () -> index.store(ONE, null, storable));
}
@Test
void shouldThrowSearchEngineExceptionOnDeleteById() throws IOException {
when(writer.deleteDocuments(any(Term.class))).thenThrow(new IOException("failed to delete"));
Index.Deleter deleter = index.delete();
assertThrows(SearchEngineException.class, () -> deleter.byId(ONE));
}
@Test
void shouldThrowSearchEngineExceptionOnDeleteAll() throws IOException {
when(writer.deleteAll()).thenThrow(new IOException("failed to delete"));
Index.Deleter deleter = index.delete();
assertThrows(SearchEngineException.class, deleter::all);
}
@Test
void shouldThrowSearchEngineExceptionOnDeleteByRepository() throws IOException {
when(writer.deleteDocuments(any(Term.class))).thenThrow(new IOException("failed to delete"));
Index.Deleter deleter = index.delete();
assertThrows(SearchEngineException.class, () -> deleter.byRepository("42"));
}
@Test
void shouldThrowSearchEngineExceptionOnClose() throws IOException {
doThrow(new IOException("failed to delete")).when(writer).close();
assertThrows(SearchEngineException.class, () -> index.close());
}
}
@CanIgnoreReturnValue
private ScoreDoc[] assertHits(String field, String value, int expectedHits) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
@@ -272,15 +247,25 @@ class LuceneIndexTest {
}
}
private <T> LuceneIndex<T> createIndex(Class<T> type) throws IOException {
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
return new LuceneIndex<>(resolver.resolve(type), createWriter());
private <T> LuceneIndex<T> createIndex(Class<T> type) {
return createIndex(type, this::createWriter);
}
private IndexWriter createWriter() throws IOException {
private <T> LuceneIndex<T> createIndex(Class<T> type, Supplier<IndexWriter> writerFactor) {
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
return new LuceneIndex<>(
new IndexParams("default", resolver.resolve(type), IndexOptions.defaults()), writerFactor
);
}
private IndexWriter createWriter() {
IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer());
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
return new IndexWriter(directory, config);
try {
return new IndexWriter(directory, config);
} catch (IOException ex) {
throw new SearchEngineException("failed to open index writer", ex);
}
}
@Value
@@ -290,11 +275,4 @@ class LuceneIndexTest {
String value;
}
@Value
@IndexedType
private static class OtherStorable {
@Indexed
String value;
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.user.User;
import javax.inject.Inject;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class LuceneInjectingIndexTaskTest {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private LuceneIndexFactory indexFactory;
private static String capturedValue;
private final SearchableTypeResolver resolver = new SearchableTypeResolver(User.class);
@BeforeEach
void cleanUp() {
capturedValue = "notAsExpected";
}
@Test
void shouldInjectAndUpdate() {
Injector injector = createInjector();
LuceneSearchableType searchableType = resolver.resolve(User.class);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
LuceneInjectingIndexTask task = new LuceneInjectingIndexTask(params, InjectingTask.class);
injector.injectMembers(task);
task.run();
assertThat(capturedValue).isEqualTo("runAsExpected");
}
private Injector createInjector() {
return Guice.createInjector(new AbstractModule() {
@Override
protected void configure() {
bind(SearchableTypeResolver.class).toInstance(resolver);
bind(LuceneIndexFactory.class).toInstance(indexFactory);
bind(String.class).toInstance("runAsExpected");
}
});
}
public static class InjectingTask implements IndexTask<String> {
private final String value;
@Inject
public InjectingTask(String value) {
this.value = value;
}
@Override
public void update(Index<String> index) {
capturedValue = value;
}
}
}

View File

@@ -42,7 +42,6 @@ import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.shiro.authz.AuthorizationException;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
@@ -70,7 +69,7 @@ class LuceneQueryBuilderTest {
private Directory directory;
@Mock
private IndexOpener opener;
private IndexManager opener;
@BeforeEach
void setUpDirectory() {
@@ -181,17 +180,6 @@ class LuceneQueryBuilderTest {
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldIgnoreHitsOfOtherType() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211"));
writer.addDocument(personDoc("Dent"));
}
QueryResult result = query(InetOrgPerson.class, "Dent");
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldThrowQueryParseExceptionOnInvalidQuery() throws IOException {
try (IndexWriter writer = writer()) {
@@ -251,17 +239,6 @@ class LuceneQueryBuilderTest {
assertThat(result.getHits()).hasSize(3);
}
@Test
void shouldReturnOnlyHitsOfTypeForExpertQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Ford", "Prefect", "Ford Prefect", "4211"));
writer.addDocument(personDoc("Prefect"));
}
QueryResult result = query(InetOrgPerson.class, "lastName:prefect");
assertThat(result.getTotalHits()).isEqualTo(1L);
}
@Test
void shouldReturnOnlyPermittedHits() throws IOException {
try (IndexWriter writer = writer()) {
@@ -302,10 +279,11 @@ class LuceneQueryBuilderTest {
QueryResult result;
try (DirectoryReader reader = DirectoryReader.open(directory)) {
when(opener.openForRead("default")).thenReturn(reader);
SearchableTypeResolver resolver = new SearchableTypeResolver(Simple.class);
LuceneSearchableType searchableType = resolver.resolve(Simple.class);
when(opener.openForRead(searchableType, "default")).thenReturn(reader);
LuceneQueryBuilder<Simple> builder = new LuceneQueryBuilder<>(
opener, "default", resolver.resolve(Simple.class), new StandardAnalyzer()
opener, "default", searchableType, new StandardAnalyzer()
);
result = builder.repository("cde").execute("content:awesome");
}
@@ -560,9 +538,9 @@ class LuceneQueryBuilderTest {
private <T> long count(Class<T> type, String queryString) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
lenient().when(opener.openForRead("default")).thenReturn(reader);
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
LuceneSearchableType searchableType = resolver.resolve(type);
lenient().when(opener.openForRead(searchableType, "default")).thenReturn(reader);
LuceneQueryBuilder<T> builder = new LuceneQueryBuilder<T>(
opener, "default", searchableType, new StandardAnalyzer()
);
@@ -572,10 +550,11 @@ class LuceneQueryBuilderTest {
private <T> 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);
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
LuceneSearchableType searchableType = resolver.resolve(type);
LuceneQueryBuilder<T> builder = new LuceneQueryBuilder<T>(
lenient().when(opener.openForRead(searchableType, "default")).thenReturn(reader);
LuceneQueryBuilder<T> builder = new LuceneQueryBuilder<>(
opener, "default", searchableType, new StandardAnalyzer()
);
if (start != null) {
@@ -597,14 +576,14 @@ class LuceneQueryBuilderTest {
private Document simpleDoc(String content) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
return document;
}
private Document permissionDoc(String content, String permission) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
document.add(new StringField(FieldNames.PERMISSION, permission, Field.Store.YES));
return document;
}
@@ -612,7 +591,7 @@ class LuceneQueryBuilderTest {
private Document repositoryDoc(String content, String repository) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
document.add(new StringField(FieldNames.REPOSITORY, repository, Field.Store.YES));
return document;
}
@@ -624,14 +603,14 @@ class LuceneQueryBuilderTest {
document.add(new TextField("displayName", displayName, Field.Store.YES));
document.add(new TextField("carLicense", carLicense, Field.Store.YES));
document.add(new StringField(FieldNames.ID, lastName, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "inetOrgPerson", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "inetOrgPerson", Field.Store.YES));
return document;
}
private Document personDoc(String lastName) {
Document document = new Document();
document.add(new TextField("lastName", lastName, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "person", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "person", Field.Store.YES));
return document;
}
@@ -644,14 +623,14 @@ class LuceneQueryBuilderTest {
document.add(new StringField("boolValue", String.valueOf(boolValue), Field.Store.YES));
document.add(new LongPoint("instantValue", instantValue.toEpochMilli()));
document.add(new StoredField("instantValue", instantValue.toEpochMilli()));
document.add(new StringField(FieldNames.TYPE, "types", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "types", Field.Store.YES));
return document;
}
private Document denyDoc(String value) {
Document document = new Document();
document.add(new TextField("value", value, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "deny", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "deny", Field.Store.YES));
return document;
}

View File

@@ -27,12 +27,20 @@ package sonia.scm.search;
import org.apache.shiro.authz.AuthorizationException;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
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.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.user.User;
import sonia.scm.work.CentralWorkQueue;
import sonia.scm.work.Task;
import java.util.Arrays;
import java.util.Collection;
@@ -46,176 +54,329 @@ import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SubjectAware("trillian")
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class LuceneSearchEngineTest {
@Mock
private IndexManager indexManager;
@Mock
private SearchableTypeResolver resolver;
@Mock
private IndexQueue indexQueue;
@Mock
private LuceneQueryBuilderFactory queryBuilderFactory;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private CentralWorkQueue centralWorkQueue;
@InjectMocks
private LuceneSearchEngine searchEngine;
@Mock
private LuceneSearchableType searchableType;
@Test
void shouldDelegateGetSearchableTypes() {
List<LuceneSearchableType> mockedTypes = Collections.singletonList(searchableType("repository"));
when(resolver.getSearchableTypes()).thenReturn(mockedTypes);
@Nested
class GetSearchableTypesTests {
Collection<SearchableType> searchableTypes = searchEngine.getSearchableTypes();
@Test
void shouldDelegateGetSearchableTypes() {
List<LuceneSearchableType> mockedTypes = Collections.singletonList(searchableType("repository"));
when(resolver.getSearchableTypes()).thenReturn(mockedTypes);
assertThat(searchableTypes).containsAll(mockedTypes);
Collection<SearchableType> searchableTypes = searchEngine.getSearchableTypes();
assertThat(searchableTypes).containsAll(mockedTypes);
}
@Test
@SubjectAware(value = "dent", permissions = "user:list")
void shouldExcludeTypesWithoutPermission() {
LuceneSearchableType repository = searchableType("repository");
LuceneSearchableType user = searchableType("user", "user:list");
LuceneSearchableType group = searchableType("group", "group:list");
List<LuceneSearchableType> mockedTypes = Arrays.asList(repository, user, group);
when(resolver.getSearchableTypes()).thenReturn(mockedTypes);
Collection<SearchableType> searchableTypes = searchEngine.getSearchableTypes();
assertThat(searchableTypes).containsOnly(repository, user);
}
private LuceneSearchableType searchableType(String name) {
return searchableType(name, null);
}
private LuceneSearchableType searchableType(String name, String permission) {
LuceneSearchableType searchableType = mock(LuceneSearchableType.class);
lenient().when(searchableType.getName()).thenReturn(name);
when(searchableType.getPermission()).thenReturn(Optional.ofNullable(permission));
return searchableType;
}
}
@Test
@SubjectAware(value = "dent", permissions = "user:list")
void shouldExcludeTypesWithoutPermission() {
LuceneSearchableType repository = searchableType("repository");
LuceneSearchableType user = searchableType("user", "user:list");
LuceneSearchableType group = searchableType("group", "group:list");
List<LuceneSearchableType> mockedTypes = Arrays.asList(repository, user, group);
when(resolver.getSearchableTypes()).thenReturn(mockedTypes);
@Nested
class SearchTests {
Collection<SearchableType> searchableTypes = searchEngine.getSearchableTypes();
@Test
@SuppressWarnings("unchecked")
void shouldDelegateSearchWithDefaults() {
LuceneQueryBuilder<Repository> mockedBuilder = mock(LuceneQueryBuilder.class);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
assertThat(searchableTypes).containsOnly(repository, user);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
when(queryBuilderFactory.<Repository>create(params)).thenReturn(mockedBuilder);
QueryBuilder<Repository> queryBuilder = searchEngine.forType(Repository.class).search();
assertThat(queryBuilder).isSameAs(mockedBuilder);
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateSearch() {
IndexOptions options = IndexOptions.naturalLanguage(Locale.GERMAN);
LuceneQueryBuilder<Repository> mockedBuilder = mock(LuceneQueryBuilder.class);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("idx", searchableType, options);
when(queryBuilderFactory.<Repository>create(params)).thenReturn(mockedBuilder);
QueryBuilder<Repository> queryBuilder = searchEngine.forType(Repository.class).withIndex("idx").withOptions(options).search();
assertThat(queryBuilder).isSameAs(mockedBuilder);
}
@Test
void shouldFailWithoutRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
SearchEngine.ForType<Repository> forType = searchEngine.forType(Repository.class);
assertThrows(AuthorizationException.class, forType::search);
}
@Test
@SuppressWarnings("unchecked")
@SubjectAware(permissions = "repository:read")
void shouldNotFailWithRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
LuceneQueryBuilder<Object> mockedBuilder = mock(LuceneQueryBuilder.class);
when(queryBuilderFactory.create(any())).thenReturn(mockedBuilder);
SearchEngine.ForType<Repository> forType = searchEngine.forType(Repository.class);
assertThat(forType.search()).isNotNull();
}
@Test
void shouldFailWithTypeNameWithoutRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolveByName("repository")).thenReturn(searchableType);
SearchEngine.ForType<Object> forType = searchEngine.forType("repository");
assertThrows(AuthorizationException.class, forType::search);
}
@Test
@SuppressWarnings("unchecked")
@SubjectAware(permissions = "repository:read")
void shouldNotFailWithTypeNameAndRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolveByName("repository")).thenReturn(searchableType);
LuceneQueryBuilder<Object> mockedBuilder = mock(LuceneQueryBuilder.class);
when(queryBuilderFactory.create(any())).thenReturn(mockedBuilder);
SearchEngine.ForType<Object> forType = searchEngine.forType("repository");
assertThat(forType.search()).isNotNull();
}
}
private LuceneSearchableType searchableType(String name) {
return searchableType(name, null);
@Nested
class IndexTests {
@Captor
private ArgumentCaptor<Task> taskCaptor;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private CentralWorkQueue.Enqueue enqueue;
@BeforeEach
void setUp() {
when(centralWorkQueue.append()).thenReturn(enqueue);
}
@Test
void shouldSubmitSimpleTask() {
mockType();
searchEngine.forType(Repository.class).update(index -> {});
verifyTaskSubmitted(LuceneSimpleIndexTask.class);
}
@Test
void shouldSubmitInjectingTask() {
mockType();
searchEngine.forType(Repository.class).update(DummyIndexTask.class);
verifyTaskSubmitted(LuceneInjectingIndexTask.class);
}
@Test
void shouldLockTypeAndDefaultIndex() {
mockType();
searchEngine.forType(Repository.class).update(DummyIndexTask.class);
verify(enqueue).locks("repository-default-index");
}
@Test
void shouldLockTypeAndIndex() {
mockType();
searchEngine.forType(Repository.class).withIndex("sample").update(DummyIndexTask.class);
verify(enqueue).locks("repository-sample-index");
}
@Test
void shouldLockSpecificResource() {
mockType();
searchEngine.forType(Repository.class).forResource("one").update(DummyIndexTask.class);
verify(enqueue).locks("repository-default-index", "one");
}
@Test
void shouldLockMultipleSpecificResources() {
mockType();
searchEngine.forType(Repository.class)
.forResource("one")
.forResource("two")
.update(DummyIndexTask.class);
verify(enqueue).locks("repository-default-index", "one");
verify(enqueue).locks("repository-default-index", "two");
}
@Test
void shouldBatchSimpleTask() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().batch(index -> {});
verifyTaskSubmitted(LuceneSimpleIndexTask.class);
}
@Test
void shouldBatchAndLock() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().batch(index -> {});
verify(enqueue).locks("repository-default-index");
}
@Test
void shouldBatchAndLockSpecificResource() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().forResource("one").batch(index -> {});
verify(enqueue).locks("repository-default-index", "one");
}
@Test
void shouldBatchAndLockMultipleSpecificResources() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().forResource("one").forResource("two").batch(index -> {});
verify(enqueue).locks("repository-default-index", "one");
verify(enqueue).locks("repository-default-index", "two");
}
@Test
void shouldBatchInjectingTask() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().batch(DummyIndexTask.class);
verifyTaskSubmitted(LuceneInjectingIndexTask.class);
}
@Test
void shouldBatchMultipleTasks() {
mockDetails(
new LuceneIndexDetails(Repository.class, "default"),
new LuceneIndexDetails(User.class, "default")
);
searchEngine.forIndices().batch(index -> {});
verify(enqueue.runAsAdmin(), times(2)).enqueue(any(Task.class));
}
@Test
void shouldFilterWithPredicate() {
mockDetails(
new LuceneIndexDetails(Repository.class, "default"),
new LuceneIndexDetails(User.class, "default")
);
searchEngine.forIndices()
.matching(details -> details.getType() == Repository.class)
.batch(index -> {});
verify(enqueue.runAsAdmin()).enqueue(any(Task.class));
}
private <T extends IndexDetails> void mockDetails(LuceneIndexDetails... details) {
for (LuceneIndexDetails detail : details) {
mockType(detail.getType());
}
when(indexManager.all()).thenAnswer(ic -> Arrays.asList(details));
}
private void verifyTaskSubmitted(Class<? extends Task> typeOfTask) {
verify(enqueue.runAsAdmin()).enqueue(taskCaptor.capture());
Task task = taskCaptor.getValue();
assertThat(task).isInstanceOf(typeOfTask);
}
private void mockType() {
mockType(Repository.class);
}
private void mockType(Class<?> type){
LuceneSearchableType searchableType = mock(LuceneSearchableType.class);
lenient().when(searchableType.getType()).thenAnswer(ic -> type);
lenient().when(searchableType.getName()).thenReturn(type.getSimpleName().toLowerCase(Locale.ENGLISH));
lenient().when(resolver.resolve(type)).thenReturn(searchableType);
}
}
private LuceneSearchableType searchableType(String name, String permission) {
LuceneSearchableType searchableType = mock(LuceneSearchableType.class);
lenient().when(searchableType.getName()).thenReturn(name);
when(searchableType.getPermission()).thenReturn(Optional.ofNullable(permission));
return searchableType;
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateGetOrCreateWithDefaultIndex() {
Index<Repository> index = mock(Index.class);
public static class DummyIndexTask implements IndexTask<Repository> {
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
when(indexQueue.<Repository>getQueuedIndex(params)).thenReturn(index);
@Override
public void update(Index<Repository> index) {
Index<Repository> idx = searchEngine.forType(Repository.class).getOrCreate();
assertThat(idx).isSameAs(index);
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateGetOrCreateIndexWithDefaults() {
Index<Repository> index = mock(Index.class);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("idx", searchableType, IndexOptions.defaults());
when(indexQueue.<Repository>getQueuedIndex(params)).thenReturn(index);
Index<Repository> idx = searchEngine.forType(Repository.class).withIndex("idx").getOrCreate();
assertThat(idx).isSameAs(index);
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateGetOrCreateIndex() {
Index<Repository> index = mock(Index.class);
IndexOptions options = IndexOptions.naturalLanguage(Locale.ENGLISH);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("default", searchableType, options);
when(indexQueue.<Repository>getQueuedIndex(params)).thenReturn(index);
Index<Repository> idx = searchEngine.forType(Repository.class).withOptions(options).getOrCreate();
assertThat(idx).isSameAs(index);
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateSearchWithDefaults() {
LuceneQueryBuilder<Repository> mockedBuilder = mock(LuceneQueryBuilder.class);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
when(queryBuilderFactory.<Repository>create(params)).thenReturn(mockedBuilder);
QueryBuilder<Repository> queryBuilder = searchEngine.forType(Repository.class).search();
assertThat(queryBuilder).isSameAs(mockedBuilder);
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateSearch() {
IndexOptions options = IndexOptions.naturalLanguage(Locale.GERMAN);
LuceneQueryBuilder<Repository> mockedBuilder = mock(LuceneQueryBuilder.class);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("idx", searchableType, options);
when(queryBuilderFactory.<Repository>create(params)).thenReturn(mockedBuilder);
QueryBuilder<Repository> queryBuilder = searchEngine.forType(Repository.class).withIndex("idx").withOptions(options).search();
assertThat(queryBuilder).isSameAs(mockedBuilder);
}
@Test
void shouldFailWithoutRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
SearchEngine.ForType<Repository> forType = searchEngine.forType(Repository.class);
assertThrows(AuthorizationException.class, forType::search);
}
@Test
@SuppressWarnings("unchecked")
@SubjectAware(permissions = "repository:read")
void shouldNotFailWithRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
LuceneQueryBuilder<Object> mockedBuilder = mock(LuceneQueryBuilder.class);
when(queryBuilderFactory.create(any())).thenReturn(mockedBuilder);
SearchEngine.ForType<Repository> forType = searchEngine.forType(Repository.class);
assertThat(forType.search()).isNotNull();
}
@Test
void shouldFailWithTypeNameWithoutRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolveByName("repository")).thenReturn(searchableType);
SearchEngine.ForType<Object> forType = searchEngine.forType("repository");
assertThrows(AuthorizationException.class, forType::search);
}
@Test
@SuppressWarnings("unchecked")
@SubjectAware(permissions = "repository:read")
void shouldNotFailWithTypeNameAndRequiredPermission() {
when(searchableType.getPermission()).thenReturn(Optional.of("repository:read"));
when(resolver.resolveByName("repository")).thenReturn(searchableType);
LuceneQueryBuilder<Object> mockedBuilder = mock(LuceneQueryBuilder.class);
when(queryBuilderFactory.create(any())).thenReturn(mockedBuilder);
SearchEngine.ForType<Object> forType = searchEngine.forType("repository");
assertThat(forType.search()).isNotNull();
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* 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.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
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.repository.Repository;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LuceneSimpleIndexTaskTest {
@Mock
private LuceneIndexFactory indexFactory;
@Mock
private LuceneIndex<Repository> index;
private final SearchableTypeResolver resolver = new SearchableTypeResolver(Repository.class);
@Test
void shouldUpdate() {
Injector injector = createInjector();
LuceneSearchableType searchableType = resolver.resolve(Repository.class);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
AtomicReference<Index<?>> ref = new AtomicReference<>();
LuceneSimpleIndexTask task = new LuceneSimpleIndexTask(params, ref::set);
injector.injectMembers(task);
when(indexFactory.create(params)).then(ic -> index);
task.run();
assertThat(index).isSameAs(ref.get());
verify(index).close();
}
private Injector createInjector() {
return Guice.createInjector(new AbstractModule() {
@Override
protected void configure() {
bind(SearchableTypeResolver.class).toInstance(resolver);
bind(LuceneIndexFactory.class).toInstance(indexFactory);
}
});
}
}

View File

@@ -0,0 +1,249 @@
/*
* 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.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.Term;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SharableIndexWriterTest {
@Mock
private IndexWriter underlyingWriter;
@Test
@SuppressWarnings("unchecked")
void shouldCreateIndexOnOpen() {
Supplier<IndexWriter> supplier = mock(Supplier.class);
SharableIndexWriter writer = new SharableIndexWriter(supplier);
verifyNoInteractions(supplier);
writer.open();
verify(supplier).get();
}
@Test
@SuppressWarnings("unchecked")
void shouldOpenWriterOnlyOnce() {
Supplier<IndexWriter> supplier = mock(Supplier.class);
SharableIndexWriter writer = new SharableIndexWriter(supplier);
writer.open();
writer.open();
writer.open();
verify(supplier).get();
}
@Test
void shouldIncreaseUsageCounter() {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.open();
assertThat(writer.getUsageCounter()).isEqualTo(3);
}
@Test
void shouldDecreaseUsageCounter() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.open();
assertThat(writer.getUsageCounter()).isEqualTo(3);
writer.close();
writer.close();
assertThat(writer.getUsageCounter()).isOne();
}
@Test
void shouldNotCloseWriterIfUsageCounterIsGreaterZero() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.open();
writer.close();
writer.close();
verify(underlyingWriter, never()).close();
}
@Test
void shouldCloseIfUsageCounterIsZero() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.close();
writer.close();
verify(underlyingWriter).close();
}
@Test
@SuppressWarnings("unchecked")
void shouldReOpen() throws IOException {
Supplier<IndexWriter> supplier = mock(Supplier.class);
when(supplier.get()).thenReturn(underlyingWriter);
SharableIndexWriter writer = new SharableIndexWriter(supplier);
writer.open();
writer.close();
writer.open();
verify(supplier, times(2)).get();
verify(underlyingWriter).close();
}
@Test
void shouldDelegateUpdates() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
Term term = new Term("field", "value");
Document document = new Document();
writer.updateDocument(term, document);
verify(underlyingWriter).updateDocument(term, document);
}
@Test
void shouldDelegateDeleteAll() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.deleteAll();
verify(underlyingWriter).deleteAll();
}
@Test
void shouldDelegateDeletes() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
Term term = new Term("field", "value");
writer.deleteDocuments(term);
verify(underlyingWriter).deleteDocuments(term);
}
@Nested
class ConcurrencyTests {
private ExecutorService executorService;
private final AtomicInteger openCounter = new AtomicInteger();
private final AtomicInteger commitCounter = new AtomicInteger();
private final AtomicInteger closeCounter = new AtomicInteger();
private final AtomicInteger invocations = new AtomicInteger();
private SharableIndexWriter writer;
@BeforeEach
void setUp() throws IOException {
executorService = Executors.newFixedThreadPool(4);
writer = new SharableIndexWriter(() -> {
openCounter.incrementAndGet();
return underlyingWriter;
});
doAnswer(ic -> commitCounter.incrementAndGet()).when(underlyingWriter).commit();
doAnswer(ic -> closeCounter.incrementAndGet()).when(underlyingWriter).close();
}
@Test
@SuppressWarnings("java:S2925") // sleep is ok to simulate some work
void shouldKeepIndexOpen() {
AtomicBoolean fail = new AtomicBoolean(false);
for (int i = 0; i < 50; i++) {
executorService.submit(() -> {
writer.open();
try {
Thread.sleep(25);
writer.deleteAll();
writer.close();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
fail.set(true);
} catch (IOException e) {
fail.set(true);
} finally {
invocations.incrementAndGet();
}
});
}
executorService.shutdown();
await().atMost(2, TimeUnit.SECONDS).until(() -> invocations.get() == 50);
assertThat(fail.get()).isFalse();
// It should be one, but it is possible that tasks finish before new added to the queue.
// This behaviour depends heavily on the cpu's of the machine which executes this test.
assertThat(openCounter.get()).isPositive().isLessThan(10);
// should be 49, but see comment above
assertThat(commitCounter.get()).isGreaterThan(40);
// should be 1, but see comment above
assertThat(closeCounter.get()).isPositive().isLessThan(10);
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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.security;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
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 sonia.scm.security.Impersonator.Session;
import javax.annotation.Nonnull;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ImpersonatorTest {
private SecurityManager securityManager = new DefaultSecurityManager();
private Impersonator impersonator;
@BeforeEach
void setUp() {
impersonator = new Impersonator(securityManager);
}
@Test
void shouldBindAndRestoreNonWebThread() {
try (Session session = impersonator.impersonate(principal("dent"))) {
assertPrincipal("dent");
}
assertThrows(UnavailableSecurityManagerException.class, SecurityUtils::getSubject);
}
@Nonnull
private SimplePrincipalCollection principal(String principal) {
return new SimplePrincipalCollection(principal, "test");
}
private void assertPrincipal(String principal) {
assertThat(SecurityUtils.getSubject().getPrincipal()).isEqualTo(principal);
}
@Nested
@ExtendWith(ShiroExtension.class)
class WithSecurityManager {
@Test
@SubjectAware("trillian")
void shouldBindAndRestoreWebThread() {
assertPrincipal("trillian");
try (Session session = impersonator.impersonate(principal("slarti"))) {
assertPrincipal("slarti");
}
assertPrincipal("trillian");
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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.update.index;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RemoveCombinedIndexTest {
private Path home;
@Mock
private SCMContextProvider contextProvider;
@InjectMocks
private RemoveCombinedIndex updateStep;
@BeforeEach
void setUp(@TempDir Path home) {
this.home = home;
when(contextProvider.resolve(any())).then(
ic -> home.resolve(ic.getArgument(0, Path.class))
);
}
@Test
void shouldRemoveIndexDirectory() throws IOException {
Path indexDirectory = home.resolve("index");
Path specificIndexDirectory = indexDirectory.resolve("repository").resolve("default");
Files.createDirectories(specificIndexDirectory);
Path helloTxt = specificIndexDirectory.resolve("hello.txt");
Files.write(helloTxt, "hello".getBytes(StandardCharsets.UTF_8));
updateStep.doUpdate();
assertThat(helloTxt).doesNotExist();
assertThat(indexDirectory).doesNotExist();
}
@Test
void shouldRemoveIndexLogDirectory() throws IOException {
Path logDirectory = home.resolve("var").resolve("data").resolve("index-log");
Files.createDirectories(logDirectory);
Path helloXml = logDirectory.resolve("hello.xml");
Files.write(helloXml, "<hello>world</hello>".getBytes(StandardCharsets.UTF_8));
updateStep.doUpdate();
assertThat(helloXml).doesNotExist();
assertThat(logDirectory).doesNotExist();
}
}

View File

@@ -24,20 +24,23 @@
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.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
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.IndexLogStore;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import java.util.Arrays;
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;
@@ -54,6 +57,16 @@ class UserIndexerTest {
@InjectMocks
private UserIndexer indexer;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<User> index;
@Mock
private IndexLogStore indexLogStore;
@Captor
private ArgumentCaptor<SerializableIndexTask<User>> captor;
@Test
void shouldReturnType() {
assertThat(indexer.getType()).isEqualTo(User.class);
@@ -64,58 +77,54 @@ class UserIndexerTest {
assertThat(indexer.getVersion()).isEqualTo(UserIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<User> index;
@Test
void shouldReturnReIndexAllClass() {
assertThat(indexer.getReIndexAllTask()).isEqualTo(UserIndexer.ReIndexAll.class);
}
private final User user = UserTestData.createTrillian();
@Test
void shouldCreateUser() {
User trillian = UserTestData.createTrillian();
@BeforeEach
void open() {
when(searchEngine.forType(User.class).getOrCreate()).thenReturn(index);
}
indexer.createStoreTask(trillian).update(index);
@Test
void shouldStore() {
indexer.open().store(user);
verify(index).store(Id.of(trillian), UserPermissions.read(trillian).asShiroString(), trillian);
}
verify(index).store(Id.of(user), "user:read:trillian", user);
}
@Test
void shouldDeleteUser() {
User trillian = UserTestData.createTrillian();
@Test
void shouldDeleteById() {
indexer.open().delete(user);
indexer.createDeleteTask(trillian).update(index);
verify(index.delete().byType()).byId(Id.of(user));
}
verify(index.delete()).byId(Id.of(trillian));
}
@Test
void shouldReIndexAll() {
when(userManager.getAll()).thenReturn(singletonList(user));
@Test
void shouldReIndexAll() {
User trillian = UserTestData.createTrillian();
User slarti = UserTestData.createSlarti();
when(userManager.getAll()).thenReturn(Arrays.asList(trillian, slarti));
indexer.open().reIndexAll();
UserIndexer.ReIndexAll reIndexAll = new UserIndexer.ReIndexAll(indexLogStore, userManager);
reIndexAll.update(index);
verify(index.delete().byType()).all();
verify(index).store(Id.of(user), "user:read:trillian", user);
}
verify(index.delete()).all();
verify(index).store(Id.of(trillian), UserPermissions.read(trillian).asShiroString(), trillian);
verify(index).store(Id.of(slarti), UserPermissions.read(slarti).asShiroString(), slarti);
}
@Test
void shouldHandleEvent() {
UserEvent event = new UserEvent(HandlerEventType.DELETE, user);
@Test
void shouldHandleEvents() {
User trillian = UserTestData.createTrillian();
UserEvent event = new UserEvent(HandlerEventType.DELETE, trillian);
indexer.handleEvent(event);
indexer.handleEvent(event);
verify(index.delete().byType()).byId(Id.of(user));
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
verify(searchEngine.forType(User.class)).update(captor.capture());
captor.getValue().update(index);
verify(index.delete()).byId(Id.of(trillian));
}
}

View File

@@ -37,6 +37,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.Authentications;
import sonia.scm.security.Impersonator;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@@ -54,7 +55,7 @@ class DefaultAdministrationContextTest {
Injector injector = Guice.createInjector();
SecurityManager securityManager = new DefaultSecurityManager();
context = new DefaultAdministrationContext(injector, securityManager);
context = new DefaultAdministrationContext(injector, new Impersonator(securityManager));
}
@Test

View File

@@ -0,0 +1,375 @@
/*
* 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.work;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Inject;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.AfterEach;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.security.Authentications;
import javax.annotation.Nonnull;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.when;
@SubjectAware("trillian")
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class DefaultCentralWorkQueueTest {
private final PrincipalCollection principal = new SimplePrincipalCollection("trillian", "test");
private static final int ITERATIONS = 50;
private static final int TIMEOUT = 1; // seconds
@Mock
private Persistence persistence;
@Nested
class WithDefaultInjector {
private MeterRegistry meterRegistry;
private DefaultCentralWorkQueue queue;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
queue = new DefaultCentralWorkQueue(Guice.createInjector(new SecurityModule()), persistence, meterRegistry);
}
private final AtomicInteger runs = new AtomicInteger();
private int counter = 0;
private int copy = -1;
@Test
void shouldRunInSequenceWithBlock() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
}
private void waitForTasks() {
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
assertThat(runs.get()).isEqualTo(ITERATIONS);
}
@Test
void shouldRunInParallel() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().enqueue(new Increase());
}
waitForTasks();
// we test if the resulting counter is less than the iteration,
// because it is extremely likely that we miss a counter update
// when we run in parallel
assertThat(counter)
.isPositive()
.isLessThan(ITERATIONS);
}
@Test
void shouldNotBlocked() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isNotNegative().isLessThan(ITERATIONS);
}
@Test
void shouldNotBlockedByDifferentResource() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().locks("copy").enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isNotNegative().isLessThan(ITERATIONS);
}
@Test
void shouldBeBlockedByParentResource() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().locks("counter", "one").enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isEqualTo(ITERATIONS);
}
@Test
void shouldBeBlockedByParentAndExactResource() {
for (int i = 0; i < ITERATIONS; i++) {
if (i % 2 == 0) {
queue.append().locks("counter", "c").enqueue(new Increase());
} else {
queue.append().locks("counter").enqueue(new Increase());
}
}
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
}
@Test
void shouldBeBlockedByParentResourceWithModelObject() {
Repository one = repository("one");
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().locks("counter", one).enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isEqualTo(ITERATIONS);
}
@Test
void shouldFinalizeOnError() {
queue.append().enqueue(() -> {
throw new IllegalStateException("failed");
});
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
}
@Test
void shouldSetThreadName() {
AtomicReference<String> threadName = new AtomicReference<>();
queue.append().enqueue(() -> threadName.set(Thread.currentThread().getName()));
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> threadName.get() != null);
assertThat(threadName.get()).startsWith("CentralWorkQueue");
}
@Test
void shouldCaptureExecutorMetrics() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().enqueue(new Increase());
}
waitForTasks();
double count = meterRegistry.get("executor.completed").functionCounter().count();
assertThat(count).isEqualTo(ITERATIONS);
}
@Test
void shouldCaptureExecutionDuration() {
queue.append().enqueue(new Increase());
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
Timer timer = meterRegistry.get(UnitOfWork.METRIC_EXECUTION).timer();
assertThat(timer.count()).isEqualTo(1);
}
@Test
void shouldCaptureWaitDuration() {
queue.append().enqueue(new Increase());
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
Timer timer = meterRegistry.get(UnitOfWork.METRIC_WAIT).timer();
assertThat(timer.count()).isEqualTo(1);
}
@Test
void shouldIncreaseBlockCount() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
waitForTasks();
int blockCount = 0;
for (Meter meter : meterRegistry.getMeters()) {
Meter.Id id = meter.getId();
if ("cwq.task.wait.duration".equals(id.getName())) {
String blocked = id.getTag("blocked");
if (blocked != null) {
blockCount += Integer.parseInt(blocked);
}
}
}
assertThat(blockCount).isPositive();
}
@Nonnull
private Repository repository(String id) {
Repository one = new Repository();
one.setId(id);
return one;
}
@AfterEach
void tearDown() {
queue.close();
}
private class Increase implements Task {
@Override
@SuppressWarnings("java:S2925")
public void run() {
int currentCounter = counter;
runs.incrementAndGet();
try {
Thread.sleep(5);
counter = currentCounter + 1;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
@Test
void shouldInjectDependencies() {
Context ctx = new Context();
DefaultCentralWorkQueue queue = new DefaultCentralWorkQueue(
Guice.createInjector(new SecurityModule(), binder -> binder.bind(Context.class).toInstance(ctx)),
persistence,
new SimpleMeterRegistry(),
() -> 2
);
queue.append().enqueue(InjectingTask.class);
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> ctx.value != null);
assertThat(ctx.value).isEqualTo("Hello");
}
@Test
void shouldLoadFromPersistence() {
Context context = new Context();
SimpleUnitOfWork one = new SimpleUnitOfWork(
21L, principal, Collections.singleton(new Resource("a")), new InjectingTask(context, "one")
);
SimpleUnitOfWork two = new SimpleUnitOfWork(
42L, principal, Collections.singleton(new Resource("a")), new InjectingTask(context, "two")
);
two.restore(42L);
when(persistence.loadAll()).thenReturn(Arrays.asList(one, two));
new DefaultCentralWorkQueue(Guice.createInjector(new SecurityModule()), persistence, new SimpleMeterRegistry());
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> context.value != null);
assertThat(context.value).isEqualTo("two");
assertThat(one.getOrder()).isEqualTo(1L);
assertThat(one.getRestoreCount()).isEqualTo(1);
assertThat(two.getOrder()).isEqualTo(2L);
assertThat(two.getRestoreCount()).isEqualTo(2);
}
@Test
void shouldRunAsUser() {
DefaultCentralWorkQueue workQueue = new DefaultCentralWorkQueue(
Guice.createInjector(new SecurityModule()), persistence, new SimpleMeterRegistry()
);
AtomicReference<Object> ref = new AtomicReference<>();
workQueue.append().enqueue(() -> ref.set(SecurityUtils.getSubject().getPrincipal()));
await().atMost(1, TimeUnit.SECONDS).until(() -> "trillian".equals(ref.get()));
}
@Test
void shouldRunAsAdminUser() {
DefaultCentralWorkQueue workQueue = new DefaultCentralWorkQueue(
Guice.createInjector(new SecurityModule()), persistence, new SimpleMeterRegistry()
);
AtomicReference<Object> ref = new AtomicReference<>();
workQueue.append().runAsAdmin().enqueue(() -> ref.set(SecurityUtils.getSubject().getPrincipal()));
await().atMost(1, TimeUnit.SECONDS).until(() -> Authentications.PRINCIPAL_SYSTEM.equals(ref.get()));
}
public static class Context {
private String value;
public void setValue(String value) {
this.value = value;
}
}
public static class InjectingTask implements Task {
private final Context context;
private final String value;
@Inject
public InjectingTask(Context context) {
this(context, "Hello");
}
public InjectingTask(Context context, String value) {
this.context = context;
this.value = value;
}
@Override
public void run() {
context.setValue(value);
}
}
public static class SecurityModule extends AbstractModule {
@Override
protected void configure() {
bind(SecurityManager.class).toInstance(SecurityUtils.getSecurityManager());
}
}
}

View File

@@ -0,0 +1,178 @@
/*
* 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.work;
import lombok.EqualsAndHashCode;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.store.Blob;
import sonia.scm.store.InMemoryBlobStore;
import sonia.scm.store.InMemoryBlobStoreFactory;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PersistenceTest {
private final PrincipalCollection principal = new SimplePrincipalCollection("trillian", "test");
@Nested
class Default {
@Mock
private PluginLoader pluginLoader;
private Persistence persistence;
@BeforeEach
void setUp() {
when(pluginLoader.getUberClassLoader()).thenReturn(PersistenceTest.class.getClassLoader());
persistence = new Persistence(pluginLoader, new InMemoryBlobStoreFactory());
}
@Test
void shouldStoreSimpleChunkOfWork() {
UnitOfWork work = new SimpleUnitOfWork(
1L, principal, Collections.singleton(new Resource("a")), new MyTask()
);
persistence.store(work);
UnitOfWork loaded = persistence.loadAll().iterator().next();
assertThat(loaded).isEqualTo(work);
}
@Test
void shouldStoreInjectingChunkOfWork() {
UnitOfWork work = new InjectingUnitOfWork(
1L, principal, Collections.singleton(new Resource("a")), MyTask.class
);
persistence.store(work);
UnitOfWork loaded = persistence.loadAll().iterator().next();
assertThat(loaded).isEqualTo(work);
}
@Test
void shouldLoadInOrder() {
store(5, 3, 1, 4, 2);
long[] orderIds = persistence.loadAll()
.stream()
.mapToLong(UnitOfWork::getOrder)
.toArray();
assertThat(orderIds).containsExactly(1, 2, 3, 4, 5);
}
@Test
void shouldRemoveAfterLoad() {
store(1, 2);
assertThat(persistence.loadAll()).hasSize(2);
assertThat(persistence.loadAll()).isEmpty();
}
@Test
void shouldFailIfNotSerializable() {
store(1);
SimpleUnitOfWork unitOfWork = new SimpleUnitOfWork(
2L, principal, Collections.emptySet(), new NotSerializable()
);
assertThrows(NonPersistableTaskException.class, () -> persistence.store(unitOfWork));
}
@Test
void shouldRemoveStored() {
store(1);
SimpleUnitOfWork chunkOfWork = new SimpleUnitOfWork(
2L, principal, Collections.emptySet(), new MyTask()
);
persistence.store(chunkOfWork);
persistence.remove(chunkOfWork);
assertThat(persistence.loadAll()).hasSize(1);
}
private void store(long... orderIds) {
for (long order : orderIds) {
persistence.store(new SimpleUnitOfWork(
order, principal, Collections.emptySet(), new MyTask()
));
}
}
}
@Test
void shouldNotFailForNonChunkOfWorkItems() throws IOException {
InMemoryBlobStore blobStore = new InMemoryBlobStore();
Persistence persistence = new Persistence(PersistenceTest.class.getClassLoader(), blobStore);
persistence.store(new SimpleUnitOfWork(
1L, principal, Collections.emptySet(), new MyTask())
);
Blob blob = blobStore.create();
try (ObjectOutputStream stream = new ObjectOutputStream(blob.getOutputStream())) {
stream.writeObject(new MyTask());
blob.commit();
}
assertThat(persistence.loadAll()).hasSize(1);
}
@EqualsAndHashCode
public static class MyTask implements Task {
@Override
public void run() {
}
}
// non static inner classes are not serializable
private class NotSerializable implements Task {
@Override
public void run() {
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.work;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ResourceTest {
@Test
void shouldReturnResourceName() {
assertThat(res("a")).hasToString("a");
}
@Test
void shouldReturnResourceNameAndId() {
assertThat(res("a", "b")).hasToString("a:b");
}
@Nested
class IsBlockedByTests {
@Test
void shouldReturnTrue() {
assertThat(res("a").isBlockedBy(res("a"))).isTrue();
assertThat(res("a", "b").isBlockedBy(res("a", "b"))).isTrue();
assertThat(res("a").isBlockedBy(res("a", "b"))).isTrue();
assertThat(res("a", "b").isBlockedBy(res("a"))).isTrue();
}
@Test
void shouldReturnFalse() {
assertThat(res("a").isBlockedBy(res("b"))).isFalse();
assertThat(res("a", "b").isBlockedBy(res("a", "c"))).isFalse();
assertThat(res("a", "b").isBlockedBy(res("c", "b"))).isFalse();
}
}
private Resource res(String name) {
return new Resource(name);
}
private Resource res(String name, String id) {
return new Resource(name, id);
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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.work;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
import lombok.Value;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.junit.jupiter.api.Test;
import javax.inject.Inject;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
class SimpleUnitOfWorkTest {
private PrincipalCollection principal = new SimplePrincipalCollection("trillian", "test");
@Test
void shouldInjectMember() {
Context context = new Context("awesome");
Injector injector = Guice.createInjector(new AbstractModule() {
@Override
protected void configure() {
bind(Context.class).toInstance(context);
}
});
SimpleTask simpleTask = new SimpleTask();
SimpleUnitOfWork unitOfWork = new SimpleUnitOfWork(1L, principal, Collections.emptySet(), simpleTask);
unitOfWork.task(injector);
simpleTask.run();
assertThat(simpleTask.value).isEqualTo("awesome");
}
@Value
public class Context {
String value;
}
public class SimpleTask implements Task {
private Context context;
private String value = "no value set";
@Inject
public void setContext(Context context) {
this.context = context;
}
@Override
public void run() {
if (context != null) {
value = context.getValue();
}
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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.work;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class ThreadCountProviderTest {
@Test
void shouldUseTwoWorkersForOneCPU() {
ThreadCountProvider provider = new ThreadCountProvider(() -> 1);
assertThat(provider.getAsInt()).isEqualTo(2);
}
@ParameterizedTest(name = "shouldUseFourWorkersFor{argumentsWithNames}CPU")
@ValueSource(ints = {2, 4, 8, 16})
void shouldUseFourWorkersForMoreThanOneCPU(int cpus) {
ThreadCountProvider provider = new ThreadCountProvider(() -> cpus);
assertThat(provider.getAsInt()).isEqualTo(4);
}
@Nested
class SystemPropertyTests {
@BeforeEach
void setUp() {
System.clearProperty(ThreadCountProvider.PROPERTY);
}
@Test
void shouldUseCountFromSystemProperty() {
ThreadCountProvider provider = new ThreadCountProvider();
System.setProperty(ThreadCountProvider.PROPERTY, "6");
assertThat(provider.getAsInt()).isEqualTo(6);
}
@ParameterizedTest
@ValueSource(strings = {"-1", "0", "100", "a", ""})
void shouldUseDefaultForInvalidValue(String value) {
ThreadCountProvider provider = new ThreadCountProvider(() -> 1);
System.setProperty(ThreadCountProvider.PROPERTY, value);
assertThat(provider.getAsInt()).isEqualTo(2);
}
}
}