fix: permalink update when slug changed (#2382)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind bug
/area core
/milestone 2.0
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
修复文章 分类 标签的 slug 改变时,没有重新生成 permalink 的问题
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2392/head
guqing 2022-09-07 16:28:11 +08:00 committed by GitHub
parent 969fcde641
commit e25a3d2232
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 380 additions and 123 deletions

View File

@ -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);
}
}

View File

@ -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<Reconciler.Request> {
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<Reconciler.Request> {
@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<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 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;
}
}

View File

@ -33,6 +33,7 @@ import run.halo.app.infra.utils.JsonUtils;
* @since 2.0.0
*/
public class PostReconciler implements Reconciler<Reconciler.Request> {
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<Reconciler.Request> {
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<String, String> 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<String> contributors = snapshots.stream()
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames,
() -> new HashSet<String>());
})
.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<String> contributors = snapshots.stream()
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames,
() -> new HashSet<String>());
})
.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<String> finalizers = oldPost.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(Post.class, oldPost.getMetadata().getName())
.ifPresent(post -> {
Set<String> 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<String, String> 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<String, String> getLabelsOrDefault(Post post) {
@ -175,6 +218,10 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
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;

View File

@ -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<Reconciler.Request> {
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<Reconciler.Request> {
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<String> 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<String> 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;
}
}

View File

@ -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<String> 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<String> 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)

View File

@ -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<Post> 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");

View File

@ -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<Tag> 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<Tag> 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;
}
}