diff --git a/api/src/main/java/run/halo/app/core/extension/content/Post.java b/api/src/main/java/run/halo/app/core/extension/content/Post.java index 5fb3f16e0..e606168df 100644 --- a/api/src/main/java/run/halo/app/core/extension/content/Post.java +++ b/api/src/main/java/run/halo/app/core/extension/content/Post.java @@ -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"; diff --git a/application/src/main/java/run/halo/app/content/AbstractEventReconciler.java b/application/src/main/java/run/halo/app/content/AbstractEventReconciler.java new file mode 100644 index 000000000..28105aa78 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/AbstractEventReconciler.java @@ -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 implements Reconciler, SmartLifecycle { + protected final RequestQueue 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; + } +} diff --git a/application/src/main/java/run/halo/app/content/CategoryPostCountUpdater.java b/application/src/main/java/run/halo/app/content/CategoryPostCountUpdater.java new file mode 100644 index 000000000..3c87fc328 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/CategoryPostCountUpdater.java @@ -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 { + + 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.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 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 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.of()); + queue.addImmediately(new PostRelatedCategories(postName, categories)); + } + + private Set 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 categoriesToUpdate = Sets.newHashSet(oldCategories); + var newCategories = post.getSpec().getCategories(); + if (newCategories != null) { + categoriesToUpdate.addAll(newCategories); + } + return categoriesToUpdate; + }) + .orElse(Set.of()); + } +} diff --git a/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java b/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java index c6b1d09bd..a1e004367 100644 --- a/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java +++ b/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java @@ -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, SmartLifecycle { + extends AbstractEventReconciler { - private final RequestQueue 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.of()); - tagQueue.addImmediately(new PostRelatedTags(postName, Sets.newHashSet(tags))); + queue.addImmediately(new PostRelatedTags(postName, Sets.newHashSet(tags))); } } diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java index 5432e5614..bfd32f007 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java @@ -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 { @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 { .build(); } - void reconcileMetadata(String name) { - client.fetch(Category.class, name).ifPresent(category -> { - Map 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 annotations = MetadataUtil.nullSafeAnnotations(category); + String newPattern = categoryPermalinkPolicy.pattern(); + annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern); } - private void addFinalizerIfNecessary(Category oldCategory) { - Set finalizers = oldCategory.getMetadata().getFinalizers(); - if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { - return; - } - client.fetch(Category.class, oldCategory.getMetadata().getName()) - .ifPresent(category -> { - Set 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 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 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 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 categoryNames) { - return and(isNull("metadata.deletionTimestamp"), - equal("spec.deleted", BooleanUtils.FALSE), - in("spec.categories", categoryNames) - ); - } - - private List listChildrenByName(String name) { - var categories = client.listAll(Category.class, new ListOptions(), Sort.unsorted()); - Map nameIdentityMap = categories.stream() - .collect(Collectors.toMap(category -> category.getMetadata().getName(), - Function.identity())); - final List children = new ArrayList<>(); - - Deque 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 childrenNames = category.getSpec().getChildren(); - if (childrenNames != null) { - deque.addAll(childrenNames); - } - } - return children; + void populatePermalink(Category category) { + category.getStatusOrDefault() + .setPermalink(categoryPermalinkPolicy.permalink(category)); } } diff --git a/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java b/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java new file mode 100644 index 000000000..8b0fab57c --- /dev/null +++ b/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java @@ -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 storedPosts = posts(); + private final List 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 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 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 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 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 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 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 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; + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/CategoryReconcilerIntegrationTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/CategoryReconcilerIntegrationTest.java deleted file mode 100644 index 248d658d6..000000000 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/CategoryReconcilerIntegrationTest.java +++ /dev/null @@ -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 storedPosts = posts(); - private final List 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 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 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 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 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 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 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 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; - } -} \ No newline at end of file