From e25a3d22329f82c2246d5b98b38a79f44e4e714f Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Wed, 7 Sep 2022 16:28:11 +0800 Subject: [PATCH] fix: permalink update when slug changed (#2382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind bug /area core /milestone 2.0 #### What this PR does / why we need it: 修复文章 分类 标签的 slug 改变时,没有重新生成 permalink 的问题 #### Which issue(s) this PR fixes: Fixes # #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note None ``` --- .../content/permalinks/ExtensionLocator.java | 24 ++ .../reconciler/CategoryReconciler.java | 68 +++++- .../extension/reconciler/PostReconciler.java | 227 +++++++++++------- .../extension/reconciler/TagReconciler.java | 68 +++++- .../app/theme/router/PermalinkIndexer.java | 8 +- .../reconciler/PostReconcilerTest.java | 16 +- .../reconciler/TagReconcilerTest.java | 92 +++++++ 7 files changed, 380 insertions(+), 123 deletions(-) create mode 100644 src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java diff --git a/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java b/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java index bb820b686..062363a8d 100644 --- a/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java +++ b/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java @@ -1,6 +1,30 @@ package run.halo.app.content.permalinks; +import java.util.Objects; import run.halo.app.extension.GroupVersionKind; +/** + * Slug can be modified, so it is not included in {@link #equals(Object)} and {@link #hashCode()}. + * + * @param gvk group version kind + * @param name extension name + * @param slug extension slug + */ public record ExtensionLocator(GroupVersionKind gvk, String name, String slug) { + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ExtensionLocator locator = (ExtensionLocator) o; + return gvk.equals(locator.gvk) && name.equals(locator.name); + } + + @Override + public int hashCode() { + return Objects.hash(gvk, name); + } } diff --git a/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java index c20ba4ddb..e9e776891 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java @@ -1,5 +1,7 @@ package run.halo.app.core.extension.reconciler; +import java.util.HashSet; +import java.util.Set; import run.halo.app.content.permalinks.CategoryPermalinkPolicy; import run.halo.app.core.extension.Category; import run.halo.app.extension.ExtensionClient; @@ -13,7 +15,7 @@ import run.halo.app.infra.utils.JsonUtils; * @since 2.0.0 */ public class CategoryReconciler implements Reconciler { - + private static final String FINALIZER_NAME = "category-protection"; private final ExtensionClient client; private final CategoryPermalinkPolicy categoryPermalinkPolicy; @@ -26,25 +28,67 @@ public class CategoryReconciler implements Reconciler { @Override public Result reconcile(Request request) { client.fetch(Category.class, request.name()) + .ifPresent(category -> { + if (isDeleted(category)) { + cleanUpResourcesAndRemoveFinalizer(request.name()); + return; + } + addFinalizerIfNecessary(category); + + reconcileStatus(request.name()); + }); + return new Result(false, null); + } + + 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 cleanUpResources(Category category) { + // remove permalink from permalink indexer + categoryPermalinkPolicy.onPermalinkDelete(category); + } + + private void cleanUpResourcesAndRemoveFinalizer(String categoryName) { + client.fetch(Category.class, categoryName).ifPresent(category -> { + cleanUpResources(category); + if (category.getMetadata().getFinalizers() != null) { + category.getMetadata().getFinalizers().remove(FINALIZER_NAME); + } + client.update(category); + }); + } + + private void reconcileStatus(String name) { + client.fetch(Category.class, name) .ifPresent(category -> { Category oldCategory = JsonUtils.deepCopy(category); - reconcilePermalink(category); + categoryPermalinkPolicy.onPermalinkDelete(oldCategory); + + category.getStatusOrDefault() + .setPermalink(categoryPermalinkPolicy.permalink(category)); + categoryPermalinkPolicy.onPermalinkAdd(category); if (!oldCategory.equals(category)) { client.update(category); } }); - return new Result(false, null); } - private void reconcilePermalink(Category category) { - category.getStatusOrDefault() - .setPermalink(categoryPermalinkPolicy.permalink(category)); - - if (category.getMetadata().getDeletionTimestamp() != null) { - categoryPermalinkPolicy.onPermalinkDelete(category); - return; - } - categoryPermalinkPolicy.onPermalinkAdd(category); + private boolean isDeleted(Category category) { + return category.getMetadata().getDeletionTimestamp() != null; } } diff --git a/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java index c82e44488..521744bb8 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java @@ -33,6 +33,7 @@ import run.halo.app.infra.utils.JsonUtils; * @since 2.0.0 */ public class PostReconciler implements Reconciler { + private static final String FINALIZER_NAME = "post-protection"; private final ExtensionClient client; private final ContentService contentService; private final PostPermalinkPolicy postPermalinkPolicy; @@ -48,110 +49,152 @@ public class PostReconciler implements Reconciler { public Result reconcile(Request request) { client.fetch(Post.class, request.name()) .ifPresent(post -> { - Post oldPost = JsonUtils.deepCopy(post); - - doReconcile(post); - permalinkReconcile(post); - - if (!oldPost.equals(post)) { - client.update(post); + if (isDeleted(post)) { + cleanUpResourcesAndRemoveFinalizer(request.name()); + return; } + addFinalizerIfNecessary(post); + + reconcileMetadata(request.name()); + reconcileStatus(request.name()); }); return new Result(false, null); } - private void permalinkReconcile(Post post) { - post.getStatusOrDefault() - .setPermalink(postPermalinkPolicy.permalink(post)); + private void reconcileMetadata(String name) { + client.fetch(Post.class, name).ifPresent(post -> { + final Post oldPost = JsonUtils.deepCopy(post); + Post.PostSpec spec = post.getSpec(); - if (Objects.equals(true, post.getSpec().getDeleted()) - || post.getMetadata().getDeletionTimestamp() != null - || Objects.equals(false, post.getSpec().getPublished())) { - postPermalinkPolicy.onPermalinkDelete(post); - return; - } - postPermalinkPolicy.onPermalinkAdd(post); + // handle logic delete + Map labels = getLabelsOrDefault(post); + if (Objects.equals(spec.getDeleted(), true)) { + labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString()); + } else { + labels.put(Post.DELETED_LABEL, Boolean.FALSE.toString()); + } + // synchronize some fields to labels to query + labels.put(Post.PHASE_LABEL, post.getStatusOrDefault().getPhase()); + labels.put(Post.VISIBLE_LABEL, + Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name()); + labels.put(Post.OWNER_LABEL, spec.getOwner()); + + if (!oldPost.equals(post)) { + client.update(post); + } + }); } - private void doReconcile(Post post) { - String name = post.getMetadata().getName(); - Post.PostSpec spec = post.getSpec(); - Post.PostStatus status = post.getStatusOrDefault(); - if (status.getPhase() == null) { - status.setPhase(Post.PostPhase.DRAFT.name()); - } + private void reconcileStatus(String name) { + client.fetch(Post.class, name).ifPresent(post -> { + final Post oldPost = JsonUtils.deepCopy(post); + postPermalinkPolicy.onPermalinkDelete(oldPost); - // handle excerpt - Post.Excerpt excerpt = spec.getExcerpt(); - if (excerpt == null) { - excerpt = new Post.Excerpt(); - excerpt.setAutoGenerate(true); - spec.setExcerpt(excerpt); - } + post.getStatusOrDefault() + .setPermalink(postPermalinkPolicy.permalink(post)); + if (isPublished(post)) { + postPermalinkPolicy.onPermalinkAdd(post); + } - if (excerpt.getAutoGenerate()) { - contentService.getContent(spec.getHeadSnapshot()) - .subscribe(content -> { - String contentRevised = content.content(); - status.setExcerpt(getExcerpt(contentRevised)); - }); - } else { - status.setExcerpt(excerpt.getRaw()); - } - - // handle contributors - String headSnapshot = post.getSpec().getHeadSnapshot(); - contentService.listSnapshots(Snapshot.SubjectRef.of(Post.KIND, name)) - .collectList() - .subscribe(snapshots -> { - List contributors = snapshots.stream() - .map(snapshot -> { - Set usernames = snapshot.getSpec().getContributors(); - return Objects.requireNonNullElseGet(usernames, - () -> new HashSet()); - }) - .flatMap(Set::stream) - .distinct() - .sorted() - .toList(); - status.setContributors(contributors); - - // update in progress status - snapshots.stream() - .filter(snapshot -> snapshot.getMetadata().getName().equals(headSnapshot)) - .findAny() - .ifPresent(snapshot -> { - status.setInProgress(!isPublished(snapshot)); + Post.PostStatus status = post.getStatusOrDefault(); + if (status.getPhase() == null) { + status.setPhase(Post.PostPhase.DRAFT.name()); + } + Post.PostSpec spec = post.getSpec(); + // handle excerpt + Post.Excerpt excerpt = spec.getExcerpt(); + if (excerpt == null) { + excerpt = new Post.Excerpt(); + excerpt.setAutoGenerate(true); + spec.setExcerpt(excerpt); + } + if (excerpt.getAutoGenerate()) { + contentService.getContent(spec.getHeadSnapshot()) + .subscribe(content -> { + String contentRevised = content.content(); + status.setExcerpt(getExcerpt(contentRevised)); }); + } else { + status.setExcerpt(excerpt.getRaw()); + } + + // handle contributors + String headSnapshot = post.getSpec().getHeadSnapshot(); + contentService.listSnapshots(Snapshot.SubjectRef.of(Post.KIND, name)) + .collectList() + .subscribe(snapshots -> { + List contributors = snapshots.stream() + .map(snapshot -> { + Set usernames = snapshot.getSpec().getContributors(); + return Objects.requireNonNullElseGet(usernames, + () -> new HashSet()); + }) + .flatMap(Set::stream) + .distinct() + .sorted() + .toList(); + status.setContributors(contributors); + + // update in progress status + snapshots.stream() + .filter( + snapshot -> snapshot.getMetadata().getName().equals(headSnapshot)) + .findAny() + .ifPresent(snapshot -> { + status.setInProgress(!isPublished(snapshot)); + }); + }); + + // handle cancel publish,has released version and published is false and not handled + if (StringUtils.isNotBlank(spec.getReleaseSnapshot()) + && Objects.equals(false, spec.getPublished()) + && !StringUtils.equals(status.getPhase(), Post.PostPhase.DRAFT.name())) { + Condition condition = new Condition(); + condition.setType("CancelledPublish"); + condition.setStatus(ConditionStatus.TRUE); + condition.setReason(condition.getType()); + condition.setMessage(StringUtils.EMPTY); + condition.setLastTransitionTime(Instant.now()); + status.getConditionsOrDefault().add(condition); + status.setPhase(Post.PostPhase.DRAFT.name()); + } + + if (!oldPost.equals(post)) { + client.update(post); + } + }); + } + + private void addFinalizerIfNecessary(Post oldPost) { + Set finalizers = oldPost.getMetadata().getFinalizers(); + if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { + return; + } + client.fetch(Post.class, oldPost.getMetadata().getName()) + .ifPresent(post -> { + Set newFinalizers = post.getMetadata().getFinalizers(); + if (newFinalizers == null) { + newFinalizers = new HashSet<>(); + post.getMetadata().setFinalizers(newFinalizers); + } + newFinalizers.add(FINALIZER_NAME); + client.update(post); }); + } - // handle cancel publish,has released version and published is false and not handled - if (StringUtils.isNotBlank(spec.getReleaseSnapshot()) - && Objects.equals(false, spec.getPublished()) - && !StringUtils.equals(status.getPhase(), Post.PostPhase.DRAFT.name())) { - Condition condition = new Condition(); - condition.setType("CancelledPublish"); - condition.setStatus(ConditionStatus.TRUE); - condition.setReason(condition.getType()); - condition.setMessage(StringUtils.EMPTY); - condition.setLastTransitionTime(Instant.now()); - status.getConditionsOrDefault().add(condition); - status.setPhase(Post.PostPhase.DRAFT.name()); - } + private void cleanUpResourcesAndRemoveFinalizer(String postName) { + client.fetch(Post.class, postName).ifPresent(post -> { + cleanUpResources(post); + if (post.getMetadata().getFinalizers() != null) { + post.getMetadata().getFinalizers().remove(FINALIZER_NAME); + } + client.update(post); + }); + } - // handle logic delete - Map labels = getLabelsOrDefault(post); - if (isDeleted(post)) { - labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString()); - // TODO do more about logic delete such as remove router - } else { - labels.put(Post.DELETED_LABEL, Boolean.FALSE.toString()); - } - // synchronize some fields to labels to query - labels.put(Post.PHASE_LABEL, status.getPhase()); - labels.put(Post.VISIBLE_LABEL, - Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name()); - labels.put(Post.OWNER_LABEL, spec.getOwner()); + private void cleanUpResources(Post post) { + // remove permalink from permalink indexer + postPermalinkPolicy.onPermalinkDelete(post); } private Map getLabelsOrDefault(Post post) { @@ -175,6 +218,10 @@ public class PostReconciler implements Reconciler { return snapshot.getSpec().getPublishTime() != null; } + private boolean isPublished(Post post) { + return Objects.equals(true, post.getSpec().getPublished()); + } + private boolean isDeleted(Post post) { return Objects.equals(true, post.getSpec().getDeleted()) || post.getMetadata().getDeletionTimestamp() != null; diff --git a/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java index 135bbf50a..fae578f6d 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java @@ -1,5 +1,7 @@ package run.halo.app.core.extension.reconciler; +import java.util.HashSet; +import java.util.Set; import run.halo.app.content.permalinks.TagPermalinkPolicy; import run.halo.app.core.extension.Tag; import run.halo.app.extension.ExtensionClient; @@ -13,6 +15,7 @@ import run.halo.app.infra.utils.JsonUtils; * @since 2.0.0 */ public class TagReconciler implements Reconciler { + private static final String FINALIZER_NAME = "tag-protection"; private final ExtensionClient client; private final TagPermalinkPolicy tagPermalinkPolicy; @@ -25,25 +28,66 @@ public class TagReconciler implements Reconciler { public Result reconcile(Request request) { client.fetch(Tag.class, request.name()) .ifPresent(tag -> { - Tag oldTag = JsonUtils.deepCopy(tag); - - this.reconcilePermalink(tag); - - if (!tag.equals(oldTag)) { - client.update(tag); + if (isDeleted(tag)) { + cleanUpResourcesAndRemoveFinalizer(request.name()); + return; } + addFinalizerIfNecessary(tag); + + this.reconcileStatus(request.name()); }); return new Result(false, null); } - private void reconcilePermalink(Tag tag) { - tag.getStatusOrDefault() - .setPermalink(tagPermalinkPolicy.permalink(tag)); + private void cleanUpResources(Tag tag) { + // remove permalink from permalink indexer + tagPermalinkPolicy.onPermalinkDelete(tag); + } - if (tag.getMetadata().getDeletionTimestamp() != null) { - tagPermalinkPolicy.onPermalinkDelete(tag); + private void addFinalizerIfNecessary(Tag oldTag) { + Set finalizers = oldTag.getMetadata().getFinalizers(); + if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { return; } - tagPermalinkPolicy.onPermalinkAdd(tag); + client.fetch(Tag.class, oldTag.getMetadata().getName()) + .ifPresent(tag -> { + Set newFinalizers = tag.getMetadata().getFinalizers(); + if (newFinalizers == null) { + newFinalizers = new HashSet<>(); + tag.getMetadata().setFinalizers(newFinalizers); + } + newFinalizers.add(FINALIZER_NAME); + client.update(tag); + }); + } + + private void cleanUpResourcesAndRemoveFinalizer(String tagName) { + client.fetch(Tag.class, tagName).ifPresent(tag -> { + cleanUpResources(tag); + if (tag.getMetadata().getFinalizers() != null) { + tag.getMetadata().getFinalizers().remove(FINALIZER_NAME); + } + client.update(tag); + }); + } + + private void reconcileStatus(String tagName) { + client.fetch(Tag.class, tagName) + .ifPresent(tag -> { + Tag oldTag = JsonUtils.deepCopy(tag); + tagPermalinkPolicy.onPermalinkDelete(oldTag); + + tag.getStatusOrDefault() + .setPermalink(tagPermalinkPolicy.permalink(tag)); + tagPermalinkPolicy.onPermalinkAdd(tag); + + if (!oldTag.equals(tag)) { + client.update(tag); + } + }); + } + + private boolean isDeleted(Tag tag) { + return tag.getMetadata().getDeletionTimestamp() != null; } } diff --git a/src/main/java/run/halo/app/theme/router/PermalinkIndexer.java b/src/main/java/run/halo/app/theme/router/PermalinkIndexer.java index caecb66d2..1275f326b 100644 --- a/src/main/java/run/halo/app/theme/router/PermalinkIndexer.java +++ b/src/main/java/run/halo/app/theme/router/PermalinkIndexer.java @@ -3,6 +3,7 @@ package run.halo.app.theme.router; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.locks.ReentrantReadWriteLock; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; @@ -52,6 +53,9 @@ public class PermalinkIndexer { List permalinks = permalinkLookup.get(locator.gvk()); if (permalinks != null) { permalinks.remove(permalink); + if (permalinks.isEmpty()) { + permalinkLookup.remove(locator.gvk()); + } } permalinkLocatorMap.remove(permalink); } finally { @@ -83,7 +87,7 @@ public class PermalinkIndexer { public List getPermalinks(GroupVersionKind gvk) { readWriteLock.readLock().lock(); try { - return permalinkLookup.get(gvk); + return Objects.requireNonNullElse(permalinkLookup.get(gvk), List.of()); } finally { readWriteLock.readLock().unlock(); } @@ -196,7 +200,7 @@ public class PermalinkIndexer { @EventListener(PermalinkIndexDeleteCommand.class) public void onPermalinkDelete(PermalinkIndexDeleteCommand deleteCommand) { - register(deleteCommand.getLocator(), deleteCommand.getPermalink()); + remove(deleteCommand.getLocator(), deleteCommand.getPermalink()); } @EventListener(PermalinkIndexUpdateCommand.class) diff --git a/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java index 599e38989..db9eba39b 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java @@ -10,10 +10,10 @@ import static org.mockito.Mockito.when; import java.util.List; import java.util.Optional; import java.util.Set; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; @@ -43,17 +43,14 @@ class PostReconcilerTest { @Mock private PostPermalinkPolicy postPermalinkPolicy; + @InjectMocks private PostReconciler postReconciler; - @BeforeEach - void setUp() { - postReconciler = new PostReconciler(client, contentService, postPermalinkPolicy); - } - @Test void reconcile() { String name = "post-A"; Post post = TestPost.postV1(); + post.getSpec().setPublished(false); post.getSpec().setHeadSnapshot("post-A-head-snapshot"); when(client.fetch(eq(Post.class), eq(name))) .thenReturn(Optional.of(post)); @@ -72,7 +69,12 @@ class PostReconcilerTest { ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); postReconciler.reconcile(new Reconciler.Request(name)); - verify(client, times(1)).update(captor.capture()); + verify(client, times(3)).update(captor.capture()); + + verify(postPermalinkPolicy, times(1)).permalink(any()); + verify(postPermalinkPolicy, times(0)).onPermalinkAdd(any()); + verify(postPermalinkPolicy, times(1)).onPermalinkDelete(any()); + verify(postPermalinkPolicy, times(0)).onPermalinkUpdate(any()); Post value = captor.getValue(); assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world"); diff --git a/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java new file mode 100644 index 000000000..e4184b830 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java @@ -0,0 +1,92 @@ +package run.halo.app.core.extension.reconciler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.content.permalinks.TagPermalinkPolicy; +import run.halo.app.core.extension.Tag; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Metadata; + +/** + * Tests for {@link TagReconciler}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class TagReconcilerTest { + @Mock + private ExtensionClient client; + + @Mock + private TagPermalinkPolicy tagPermalinkPolicy; + + @InjectMocks + private TagReconciler tagReconciler; + + @Test + void reconcile() { + Tag tag = tag(); + when(client.fetch(eq(Tag.class), eq("fake-tag"))) + .thenReturn(Optional.of(tag)); + when(tagPermalinkPolicy.permalink(any())) + .thenAnswer(arg -> "/tags/" + tag.getSpec().getSlug()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Tag.class); + + tagReconciler.reconcile(new TagReconciler.Request("fake-tag")); + + verify(client, times(2)).update(captor.capture()); + verify(tagPermalinkPolicy, times(1)).onPermalinkAdd(any()); + verify(tagPermalinkPolicy, times(1)).onPermalinkDelete(any()); + Tag capture = captor.getValue(); + assertThat(capture.getStatus().getPermalink()).isEqualTo("/tags/fake-slug"); + + // change slug + tag.getSpec().setSlug("new-slug"); + tagReconciler.reconcile(new TagReconciler.Request("fake-tag")); + verify(client, times(3)).update(captor.capture()); + verify(tagPermalinkPolicy, times(2)).onPermalinkAdd(any()); + verify(tagPermalinkPolicy, times(2)).onPermalinkDelete(any()); + assertThat(capture.getStatus().getPermalink()).isEqualTo("/tags/new-slug"); + } + + @Test + void reconcileDelete() { + Tag tag = tag(); + tag.getMetadata().setDeletionTimestamp(Instant.now()); + when(client.fetch(eq(Tag.class), eq("fake-tag"))) + .thenReturn(Optional.of(tag)); + ArgumentCaptor captor = ArgumentCaptor.forClass(Tag.class); + + tagReconciler.reconcile(new TagReconciler.Request("fake-tag")); + verify(client, times(1)).update(captor.capture()); + verify(tagPermalinkPolicy, times(0)).onPermalinkAdd(any()); + verify(tagPermalinkPolicy, times(1)).onPermalinkDelete(any()); + verify(tagPermalinkPolicy, times(0)).permalink(any()); + } + + Tag tag() { + Tag tag = new Tag(); + tag.setMetadata(new Metadata()); + tag.getMetadata().setName("fake-tag"); + + tag.setSpec(new Tag.TagSpec()); + tag.getSpec().setSlug("fake-slug"); + + tag.setStatus(new Tag.TagStatus()); + return tag; + } +} \ No newline at end of file