refactor: refine statistical approach for tallying posts by category association (#5671)

pull/5719/head
guqing 2024-04-22 15:48:44 +08:00 committed by GitHub
parent 4d10d73ba9
commit b4b6693732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 483 additions and 451 deletions

View File

@ -42,6 +42,8 @@ public class Post extends AbstractExtension {
public static final String LAST_RELEASED_SNAPSHOT_ANNO =
"content.halo.run/last-released-snapshot";
public static final String LAST_ASSOCIATED_TAGS_ANNO = "content.halo.run/last-associated-tags";
public static final String LAST_ASSOCIATED_CATEGORIES_ANNO =
"content.halo.run/last-associated-categories";
public static final String STATS_ANNO = "content.halo.run/stats";

View File

@ -0,0 +1,66 @@
package run.halo.app.content;
import java.time.Duration;
import java.time.Instant;
import org.springframework.context.SmartLifecycle;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.DefaultController;
import run.halo.app.extension.controller.DefaultQueue;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.RequestQueue;
/**
* An abstract class for reconciling events.
*
* @author guqing
* @since 2.15.0
*/
public abstract class AbstractEventReconciler<E> implements Reconciler<E>, SmartLifecycle {
protected final RequestQueue<E> queue;
protected final Controller controller;
protected volatile boolean running = false;
protected final ExtensionClient client;
private final String controllerName;
protected AbstractEventReconciler(String controllerName, ExtensionClient client) {
this.client = client;
this.controllerName = controllerName;
this.queue = new DefaultQueue<>(Instant::now);
this.controller = this.setupWith(null);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return new DefaultController<>(
controllerName,
this,
queue,
null,
Duration.ofMillis(100),
Duration.ofMinutes(10)
);
}
@Override
public void start() {
controller.start();
running = true;
}
@Override
public void stop() {
running = false;
controller.dispose();
}
@Override
public boolean isRunning() {
return running;
}
}

View File

@ -0,0 +1,161 @@
package run.halo.app.content;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import com.google.common.collect.Sets;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.event.post.PostDeletedEvent;
import run.halo.app.event.post.PostUpdatedEvent;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
import run.halo.app.infra.utils.JsonUtils;
/**
* A class used to update the post count of the category when the post changes.
*
* @author guqing
* @since 2.15.0
*/
@Component
public class CategoryPostCountUpdater
extends AbstractEventReconciler<CategoryPostCountUpdater.PostRelatedCategories> {
private final CategoryPostCountService categoryPostCountService;
public CategoryPostCountUpdater(ExtensionClient client) {
super(CategoryPostCountUpdater.class.getName(), client);
this.categoryPostCountService = new CategoryPostCountService(client);
}
@Override
public Result reconcile(PostRelatedCategories request) {
var categoryChanges = request.categoryChanges();
categoryPostCountService.recalculatePostCount(categoryChanges);
client.fetch(Post.class, request.postName()).ifPresent(post -> {
var categories = defaultIfNull(post.getSpec().getCategories(), List.<String>of());
var annotations = MetadataUtil.nullSafeAnnotations(post);
var categoryAnno = JsonUtils.objectToJson(categories);
var oldCategoryAnno = annotations.get(Post.LAST_ASSOCIATED_CATEGORIES_ANNO);
if (!categoryAnno.equals(oldCategoryAnno)) {
annotations.put(Post.LAST_ASSOCIATED_CATEGORIES_ANNO, categoryAnno);
client.update(post);
}
});
return Result.doNotRetry();
}
static class CategoryPostCountService {
private final ExtensionClient client;
public CategoryPostCountService(ExtensionClient client) {
this.client = client;
}
public void recalculatePostCount(Collection<String> categoryNames) {
for (String categoryName : categoryNames) {
recalculatePostCount(categoryName);
}
}
public void recalculatePostCount(String categoryName) {
var totalPostCount = countTotalPosts(categoryName);
var visiblePostCount = countVisiblePosts(categoryName);
client.fetch(Category.class, categoryName).ifPresent(category -> {
category.getStatusOrDefault().setPostCount(totalPostCount);
category.getStatusOrDefault().setVisiblePostCount(visiblePostCount);
client.update(category);
});
}
private int countTotalPosts(String categoryName) {
var postListOptions = new ListOptions();
postListOptions.setFieldSelector(FieldSelector.of(
basePostQuery(categoryName)
));
return (int) client.listBy(Post.class, postListOptions, PageRequestImpl.ofSize(1))
.getTotal();
}
private int countVisiblePosts(String categoryName) {
var postListOptions = new ListOptions();
var fieldQuery = and(basePostQuery(categoryName),
equal("spec.visible", Post.VisibleEnum.PUBLIC.name())
);
var labelSelector = LabelSelector.builder()
.eq(Post.PUBLISHED_LABEL, BooleanUtils.TRUE)
.build();
postListOptions.setFieldSelector(FieldSelector.of(fieldQuery));
postListOptions.setLabelSelector(labelSelector);
return (int) client.listBy(Post.class, postListOptions, PageRequestImpl.ofSize(1))
.getTotal();
}
private static Query basePostQuery(String categoryName) {
return and(isNull("metadata.deletionTimestamp"),
equal("spec.deleted", BooleanUtils.FALSE),
equal("spec.categories", categoryName)
);
}
}
public record PostRelatedCategories(String postName, Collection<String> categoryChanges) {
}
@EventListener(PostUpdatedEvent.class)
public void onPostUpdated(PostUpdatedEvent event) {
var postName = event.getName();
var changes = calcCategoriesToUpdate(event.getName());
queue.addImmediately(new PostRelatedCategories(postName, changes));
}
@EventListener(PostDeletedEvent.class)
public void onPostDeleted(PostDeletedEvent event) {
var postName = event.getName();
var categories = defaultIfNull(event.getPost().getSpec().getCategories(),
List.<String>of());
queue.addImmediately(new PostRelatedCategories(postName, categories));
}
private Set<String> calcCategoriesToUpdate(String postName) {
return client.fetch(Post.class, postName)
.map(post -> {
var annotations = MetadataUtil.nullSafeAnnotations(post);
var oldCategories =
Optional.ofNullable(annotations.get(Post.LAST_ASSOCIATED_CATEGORIES_ANNO))
.filter(StringUtils::isNotBlank)
.map(categoriesJson -> JsonUtils.jsonToObject(categoriesJson,
String[].class))
.orElse(new String[0]);
Set<String> categoriesToUpdate = Sets.newHashSet(oldCategories);
var newCategories = post.getSpec().getCategories();
if (newCategories != null) {
categoriesToUpdate.addAll(newCategories);
}
return categoriesToUpdate;
})
.orElse(Set.of());
}
}

View File

@ -6,13 +6,10 @@ import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import com.google.common.collect.Sets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Post;
@ -25,12 +22,6 @@ import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.DefaultController;
import run.halo.app.extension.controller.DefaultQueue;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.RequestQueue;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
import run.halo.app.infra.utils.JsonUtils;
@ -43,24 +34,10 @@ import run.halo.app.infra.utils.JsonUtils;
*/
@Component
public class TagPostCountUpdater
implements Reconciler<TagPostCountUpdater.PostRelatedTags>, SmartLifecycle {
extends AbstractEventReconciler<TagPostCountUpdater.PostRelatedTags> {
private final RequestQueue<PostRelatedTags> tagQueue;
private final Controller postEventController;
private final ExtensionClient client;
private volatile boolean running = false;
/**
* Construct a {@link TagPostCountUpdater} with the given {@link ExtensionClient}.
*/
public TagPostCountUpdater(ExtensionClient client) {
this.client = client;
this.tagQueue = new DefaultQueue<>(Instant::now);
this.postEventController = this.setupWith(null);
super(TagPostCountUpdater.class.getName(), client);
}
@Override
@ -84,36 +61,6 @@ public class TagPostCountUpdater
return Result.doNotRetry();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return new DefaultController<>(
this.getClass().getName(),
this,
tagQueue,
null,
Duration.ofMillis(100),
Duration.ofMinutes(10)
);
}
@Override
public void start() {
postEventController.start();
running = true;
}
@Override
public void stop() {
running = false;
postEventController.dispose();
}
@Override
public boolean isRunning() {
return running;
}
/**
* Listen to post event to calculate post related to tag for updating.
*/
@ -122,14 +69,14 @@ public class TagPostCountUpdater
var postName = postEvent.getName();
if (postEvent instanceof PostUpdatedEvent) {
var tagsToUpdate = calcTagsToUpdate(postEvent.getName());
tagQueue.addImmediately(new PostRelatedTags(postName, tagsToUpdate));
queue.addImmediately(new PostRelatedTags(postName, tagsToUpdate));
return;
}
if (postEvent instanceof PostDeletedEvent deletedEvent) {
var tags = defaultIfNull(deletedEvent.getPost().getSpec().getTags(),
List.<String>of());
tagQueue.addImmediately(new PostRelatedTags(postName, Sets.newHashSet(tags)));
queue.addImmediately(new PostRelatedTags(postName, Sets.newHashSet(tags)));
}
}

View File

@ -1,40 +1,21 @@
package run.halo.app.core.extension.reconciler;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.in;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
import run.halo.app.infra.utils.JsonUtils;
/**
* Reconciler for {@link Category}.
@ -51,22 +32,22 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
@Override
public Result reconcile(Request request) {
return client.fetch(Category.class, request.name())
.map(category -> {
if (category.isDeleted()) {
cleanUpResourcesAndRemoveFinalizer(request.name());
return new Result(false, null);
client.fetch(Category.class, request.name())
.ifPresent(category -> {
if (ExtensionUtil.isDeleted(category)) {
if (removeFinalizers(category.getMetadata(), Set.of(FINALIZER_NAME))) {
client.update(category);
}
return;
}
addFinalizerIfNecessary(category);
addFinalizers(category.getMetadata(), Set.of(FINALIZER_NAME));
reconcileMetadata(request.name());
populatePermalinkPattern(category);
populatePermalink(category);
reconcileStatusPermalink(request.name());
reconcileStatusPosts(request.name());
return new Result(true, Duration.ofMinutes(1));
})
.orElseGet(() -> new Result(false, null));
client.update(category);
});
return Result.doNotRetry();
}
@Override
@ -76,136 +57,14 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
.build();
}
void reconcileMetadata(String name) {
client.fetch(Category.class, name).ifPresent(category -> {
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(category);
String oldPermalinkPattern = annotations.get(Constant.PERMALINK_PATTERN_ANNO);
String newPattern = categoryPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern);
if (!StringUtils.equals(oldPermalinkPattern, newPattern)) {
client.update(category);
}
});
void populatePermalinkPattern(Category category) {
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(category);
String newPattern = categoryPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern);
}
private void addFinalizerIfNecessary(Category oldCategory) {
Set<String> finalizers = oldCategory.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(Category.class, oldCategory.getMetadata().getName())
.ifPresent(category -> {
Set<String> newFinalizers = category.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
category.getMetadata().setFinalizers(newFinalizers);
}
newFinalizers.add(FINALIZER_NAME);
client.update(category);
});
}
private void cleanUpResourcesAndRemoveFinalizer(String categoryName) {
client.fetch(Category.class, categoryName).ifPresent(category -> {
if (category.getMetadata().getFinalizers() != null) {
category.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
client.update(category);
});
}
private void reconcileStatusPermalink(String name) {
client.fetch(Category.class, name)
.ifPresent(category -> {
Category oldCategory = JsonUtils.deepCopy(category);
category.getStatusOrDefault()
.setPermalink(categoryPermalinkPolicy.permalink(category));
if (!oldCategory.equals(category)) {
client.update(category);
}
});
}
private void reconcileStatusPosts(String name) {
client.fetch(Category.class, name).ifPresent(category -> {
Category oldCategory = JsonUtils.deepCopy(category);
populatePosts(category);
if (!oldCategory.equals(category)) {
client.update(category);
}
});
}
private void populatePosts(Category category) {
String name = category.getMetadata().getName();
Set<String> categoryNames = listChildrenByName(name)
.stream()
.map(item -> item.getMetadata().getName())
.collect(Collectors.toSet());
var totalPostCount = countTotalPosts(categoryNames);
category.getStatusOrDefault().setPostCount(totalPostCount);
var visiblePostCount = countVisiblePosts(categoryNames);
category.getStatusOrDefault().setVisiblePostCount(visiblePostCount);
}
int countTotalPosts(Set<String> categoryNames) {
var postListOptions = new ListOptions();
postListOptions.setFieldSelector(FieldSelector.of(
basePostQuery(categoryNames)
));
return (int) client.listBy(Post.class, postListOptions, PageRequestImpl.ofSize(1))
.getTotal();
}
int countVisiblePosts(Set<String> categoryNames) {
var postListOptions = new ListOptions();
var fieldQuery = and(basePostQuery(categoryNames),
equal("spec.visible", Post.VisibleEnum.PUBLIC.name())
);
var labelSelector = LabelSelector.builder()
.eq(Post.PUBLISHED_LABEL, BooleanUtils.TRUE)
.build();
postListOptions.setFieldSelector(FieldSelector.of(fieldQuery));
postListOptions.setLabelSelector(labelSelector);
return (int) client.listBy(Post.class, postListOptions, PageRequestImpl.ofSize(1))
.getTotal();
}
private static Query basePostQuery(Set<String> categoryNames) {
return and(isNull("metadata.deletionTimestamp"),
equal("spec.deleted", BooleanUtils.FALSE),
in("spec.categories", categoryNames)
);
}
private List<Category> listChildrenByName(String name) {
var categories = client.listAll(Category.class, new ListOptions(), Sort.unsorted());
Map<String, Category> nameIdentityMap = categories.stream()
.collect(Collectors.toMap(category -> category.getMetadata().getName(),
Function.identity()));
final List<Category> children = new ArrayList<>();
Deque<String> deque = new ArrayDeque<>();
deque.add(name);
while (!deque.isEmpty()) {
String itemName = deque.poll();
Category category = nameIdentityMap.get(itemName);
if (category == null) {
continue;
}
children.add(category);
List<String> childrenNames = category.getSpec().getChildren();
if (childrenNames != null) {
deque.addAll(childrenNames);
}
}
return children;
void populatePermalink(Category category) {
category.getStatusOrDefault()
.setPermalink(categoryPermalinkPolicy.permalink(category));
}
}

View File

@ -0,0 +1,227 @@
package run.halo.app.content;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import java.util.List;
import java.util.Set;
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.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.annotation.DirtiesContext;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionStoreUtil;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.index.IndexerFactory;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
/**
* Tests for {@link CategoryPostCountUpdater}.
*
* @author guqing
* @since 2.15.0
*/
class CategoryPostCountUpdaterTest {
@Nested
@DirtiesContext
@SpringBootTest
class CategoryPostCountServiceIntegrationTest {
private final List<Post> storedPosts = posts();
private final List<Category> storedCategories = categories();
@Autowired
private SchemeManager schemeManager;
@SpyBean
private ExtensionClient client;
@Autowired
private ReactiveExtensionClient reactiveClient;
@Autowired
private ReactiveExtensionStoreClient storeClient;
@Autowired
private IndexerFactory indexerFactory;
private CategoryPostCountUpdater.CategoryPostCountService categoryPostCountService;
Mono<Extension> deleteImmediately(Extension extension) {
var name = extension.getMetadata().getName();
var scheme = schemeManager.get(extension.getClass());
// un-index
var indexer = indexerFactory.getIndexer(extension.groupVersionKind());
indexer.unIndexRecord(extension.getMetadata().getName());
// delete from db
var storeName = ExtensionStoreUtil.buildStoreName(scheme, name);
return storeClient.delete(storeName, extension.getMetadata().getVersion())
.thenReturn(extension);
}
@BeforeEach
void setUp() {
categoryPostCountService =
new CategoryPostCountUpdater.CategoryPostCountService(client);
Flux.fromIterable(storedPosts)
.flatMap(post -> reactiveClient.create(post))
.as(StepVerifier::create)
.expectNextCount(storedPosts.size())
.verifyComplete();
Flux.fromIterable(storedCategories)
.flatMap(category -> reactiveClient.create(category))
.as(StepVerifier::create)
.expectNextCount(storedCategories.size())
.verifyComplete();
}
@AfterEach
void tearDown() {
Flux.fromIterable(storedPosts)
.flatMap(this::deleteImmediately)
.as(StepVerifier::create)
.expectNextCount(storedPosts.size())
.verifyComplete();
Flux.fromIterable(storedCategories)
.flatMap(this::deleteImmediately)
.as(StepVerifier::create)
.expectNextCount(storedCategories.size())
.verifyComplete();
}
@Test
void reconcileStatusPostForCategoryA() {
categoryPostCountService.recalculatePostCount(Set.of("category-A"));
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client).update(captor.capture());
var value = captor.getValue();
assertThat(value.getStatusOrDefault().getPostCount()).isEqualTo(1);
assertThat(value.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
}
@Test
void reconcileStatusPostForCategoryB() {
categoryPostCountService.recalculatePostCount(Set.of("category-B"));
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client).update(captor.capture());
var category = captor.getValue();
assertThat(category.getStatusOrDefault().getPostCount()).isEqualTo(1);
assertThat(category.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
}
@Test
void reconcileStatusPostForCategoryC() {
categoryPostCountService.recalculatePostCount(Set.of("category-C"));
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client).update(captor.capture());
var value = captor.getValue();
assertThat(value.getStatusOrDefault().getPostCount()).isEqualTo(2);
assertThat(value.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
}
@Test
void reconcileStatusPostForCategoryD() {
categoryPostCountService.recalculatePostCount(Set.of("category-D"));
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client).update(captor.capture());
var value = captor.getValue();
assertThat(value.getStatusOrDefault().postCount).isEqualTo(1);
assertThat(value.getStatusOrDefault().visiblePostCount).isEqualTo(0);
}
private List<Category> categories() {
/*
* |-A(post-4)
* |-B(post-3)
* |-|-C(post-2,post-1)
* |-D(post-1)
*/
Category categoryA = category("category-A");
categoryA.getSpec().setChildren(List.of("category-B", "category-D"));
Category categoryB = category("category-B");
categoryB.getSpec().setChildren(List.of("category-C"));
Category categoryC = category("category-C");
Category categoryD = category("category-D");
return List.of(categoryA, categoryB, categoryC, categoryD);
}
private Category category(String name) {
Category category = new Category();
Metadata metadata = new Metadata();
metadata.setName(name);
category.setMetadata(metadata);
category.setSpec(new Category.CategorySpec());
category.setStatus(new Category.CategoryStatus());
category.getSpec().setDisplayName("display-name");
category.getSpec().setSlug("slug");
category.getSpec().setPriority(0);
return category;
}
private List<Post> posts() {
/*
* |-A(post-4)
* |-B(post-3)
* |-|-C(post-2,post-1)
* |-D(post-1)
*/
Post post1 = fakePost();
post1.getMetadata().setName("post-1");
post1.getSpec().setCategories(List.of("category-D", "category-C"));
post1.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
Post post2 = fakePost();
post2.getMetadata().setName("post-2");
post2.getSpec().setCategories(List.of("category-C"));
post2.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
Post post3 = fakePost();
post3.getMetadata().setName("post-3");
post3.getSpec().setCategories(List.of("category-B"));
post3.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
Post post4 = fakePost();
post4.getMetadata().setName("post-4");
post4.getSpec().setCategories(List.of("category-A"));
post4.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
return List.of(post1, post2, post3, post4);
}
Post fakePost() {
var post = TestPost.postV1();
post.getSpec().setAllowComment(true);
post.getSpec().setDeleted(false);
post.getSpec().setExcerpt(new Post.Excerpt());
post.getSpec().getExcerpt().setAutoGenerate(false);
post.getSpec().setPinned(false);
post.getSpec().setPriority(0);
post.getSpec().setPublish(false);
post.getSpec().setSlug("fake-post");
return post;
}
}
}

View File

@ -1,230 +0,0 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.annotation.DirtiesContext;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionStoreUtil;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.index.IndexerFactory;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
/**
* Tests for {@link CategoryReconciler}.
*
* @author guqing
* @since 2.0.0
*/
@DirtiesContext
@SpringBootTest
class CategoryReconcilerIntegrationTest {
private final List<Post> storedPosts = posts();
private final List<Category> storedCategories = categories();
@Autowired
private SchemeManager schemeManager;
@SpyBean
private ExtensionClient client;
@Autowired
private ReactiveExtensionClient reactiveClient;
@Autowired
private ReactiveExtensionStoreClient storeClient;
@Autowired
private IndexerFactory indexerFactory;
@Autowired
private CategoryReconciler categoryReconciler;
Mono<Extension> deleteImmediately(Extension extension) {
var name = extension.getMetadata().getName();
var scheme = schemeManager.get(extension.getClass());
// un-index
var indexer = indexerFactory.getIndexer(extension.groupVersionKind());
indexer.unIndexRecord(extension.getMetadata().getName());
// delete from db
var storeName = ExtensionStoreUtil.buildStoreName(scheme, name);
return storeClient.delete(storeName, extension.getMetadata().getVersion())
.thenReturn(extension);
}
@BeforeEach
void setUp() {
Flux.fromIterable(storedPosts)
.flatMap(post -> reactiveClient.create(post))
.as(StepVerifier::create)
.expectNextCount(storedPosts.size())
.verifyComplete();
Flux.fromIterable(storedCategories)
.flatMap(category -> reactiveClient.create(category))
.as(StepVerifier::create)
.expectNextCount(storedCategories.size())
.verifyComplete();
}
@AfterEach
void tearDown() {
Flux.fromIterable(storedPosts)
.flatMap(this::deleteImmediately)
.as(StepVerifier::create)
.expectNextCount(storedPosts.size())
.verifyComplete();
Flux.fromIterable(storedCategories)
.flatMap(this::deleteImmediately)
.as(StepVerifier::create)
.expectNextCount(storedCategories.size())
.verifyComplete();
}
@Test
void reconcileStatusPostForCategoryA() {
reconcileStatusPostPilling("category-A");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(3)).update(captor.capture());
var values = captor.getAllValues();
assertThat(values.get(2).getStatusOrDefault().getPostCount()).isEqualTo(4);
assertThat(
captor.getAllValues().get(2).getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
}
@Test
void reconcileStatusPostForCategoryB() {
reconcileStatusPostPilling("category-B");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(3)).update(captor.capture());
Category category = captor.getAllValues().get(2);
assertThat(category.getStatusOrDefault().getPostCount()).isEqualTo(3);
assertThat(category.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
}
@Test
void reconcileStatusPostForCategoryC() {
reconcileStatusPostPilling("category-C");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(3)).update(captor.capture());
var value = captor.getAllValues().get(2);
assertThat(value.getStatusOrDefault().getPostCount()).isEqualTo(2);
assertThat(value.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
}
@Test
void reconcileStatusPostForCategoryD() {
reconcileStatusPostPilling("category-D");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(3)).update(captor.capture());
var value = captor.getAllValues().get(2);
assertThat(value.getStatusOrDefault().postCount).isEqualTo(1);
assertThat(value.getStatusOrDefault().visiblePostCount).isEqualTo(0);
}
private void reconcileStatusPostPilling(String reconcileCategoryName) {
Reconciler.Result result =
categoryReconciler.reconcile(new Reconciler.Request(reconcileCategoryName));
assertThat(result.reEnqueue()).isTrue();
assertThat(result.retryAfter()).isEqualTo(Duration.ofMinutes(1));
}
private List<Category> categories() {
/*
* |-A(post-4)
* |-B(post-3)
* |-|-C(post-2,post-1)
* |-D(post-1)
*/
Category categoryA = category("category-A");
categoryA.getSpec().setChildren(List.of("category-B", "category-D"));
Category categoryB = category("category-B");
categoryB.getSpec().setChildren(List.of("category-C"));
Category categoryC = category("category-C");
Category categoryD = category("category-D");
return List.of(categoryA, categoryB, categoryC, categoryD);
}
private Category category(String name) {
Category category = new Category();
Metadata metadata = new Metadata();
metadata.setName(name);
category.setMetadata(metadata);
category.getMetadata().setFinalizers(Set.of(CategoryReconciler.FINALIZER_NAME));
category.setSpec(new Category.CategorySpec());
category.setStatus(new Category.CategoryStatus());
category.getSpec().setDisplayName("display-name");
category.getSpec().setSlug("slug");
category.getSpec().setPriority(0);
return category;
}
private List<Post> posts() {
/*
* |-A(post-4)
* |-B(post-3)
* |-|-C(post-2,post-1)
* |-D(post-1)
*/
Post post1 = fakePost();
post1.getMetadata().setName("post-1");
post1.getSpec().setCategories(List.of("category-D", "category-C"));
post1.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
Post post2 = fakePost();
post2.getMetadata().setName("post-2");
post2.getSpec().setCategories(List.of("category-C"));
post2.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
Post post3 = fakePost();
post3.getMetadata().setName("post-3");
post3.getSpec().setCategories(List.of("category-B"));
post3.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
Post post4 = fakePost();
post4.getMetadata().setName("post-4");
post4.getSpec().setCategories(List.of("category-A"));
post4.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
return List.of(post1, post2, post3, post4);
}
Post fakePost() {
var post = TestPost.postV1();
post.getSpec().setAllowComment(true);
post.getSpec().setDeleted(false);
post.getSpec().setExcerpt(new Post.Excerpt());
post.getSpec().getExcerpt().setAutoGenerate(false);
post.getSpec().setPinned(false);
post.getSpec().setPriority(0);
post.getSpec().setPublish(false);
post.getSpec().setSlug("fake-post");
return post;
}
}