feat: add comment custom endpoint for list view (#2412)

#### What type of PR is this?
/kind feature
/milestone 2.0
/area core
/kind api-change

#### What this PR does / why we need it:
- 新增评论列表自定义 API,支持过滤和排序条件
- 新增评论 Reconciler 以支持:
  -  是否有新回复
  - 最新回复时间
  - 上次查看回复时间
  - 未读回复数量
- 新增评论主体信息获取扩展点 `CommentSubject` 用于获取评论的 subject 信息 ,默认实现 `Post` 模型和 `SinglePage`
#### Which issue(s) this PR fixes:
Fixes #2409

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?
```release-note
新增评论列表自定义 API (包括回复)并支持过滤和排序条件
新增发表评论和回复的自定义 API
支持是否有新回复及未读回复数量
```
pull/2419/head
guqing 2022-09-19 16:38:13 +08:00 committed by GitHub
parent 510f155e05
commit 9fdc9c1bb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2643 additions and 13 deletions

View File

@ -12,6 +12,7 @@ import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
import run.halo.app.content.permalinks.PostPermalinkPolicy;
import run.halo.app.content.permalinks.TagPermalinkPolicy;
import run.halo.app.core.extension.Category;
import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Menu;
import run.halo.app.core.extension.MenuItem;
import run.halo.app.core.extension.Plugin;
@ -24,6 +25,7 @@ import run.halo.app.core.extension.Theme;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.reconciler.CategoryReconciler;
import run.halo.app.core.extension.reconciler.CommentReconciler;
import run.halo.app.core.extension.reconciler.MenuItemReconciler;
import run.halo.app.core.extension.reconciler.MenuReconciler;
import run.halo.app.core.extension.reconciler.PluginReconciler;
@ -195,6 +197,14 @@ public class ExtensionConfiguration {
.extension(new SinglePage())
.build();
}
@Bean
Controller commentController(ExtensionClient client) {
return new ControllerBuilder("comment-controller", client)
.reconciler(new CommentReconciler(client))
.extension(new Comment())
.build();
}
}
}

View File

@ -0,0 +1,43 @@
package run.halo.app.content.comment;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.Assert;
import run.halo.app.core.extension.Comment;
/**
* <p>The creator info of the comment.</p>
* This {@link CommentEmailOwner} is only applicable to the user who is allowed to comment
* without logging in.
*
* @param email email for comment owner
* @param avatar avatar for comment owner
* @param displayName display name for comment owner
* @param website website for comment owner
*/
public record CommentEmailOwner(String email, String avatar, String displayName, String website) {
public CommentEmailOwner {
Assert.hasText(displayName, "The 'displayName' must not be empty.");
}
/**
* Converts {@link CommentEmailOwner} to {@link Comment.CommentOwner}.
*
* @return a comment owner
*/
public Comment.CommentOwner toCommentOwner() {
Comment.CommentOwner commentOwner = new Comment.CommentOwner();
commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL);
// email nullable
commentOwner.setName(StringUtils.defaultString(email, StringUtils.EMPTY));
commentOwner.setDisplayName(displayName);
Map<String, String> annotations = new LinkedHashMap<>();
commentOwner.setAnnotations(annotations);
annotations.put(Comment.CommentOwner.AVATAR_ANNO, avatar);
annotations.put(Comment.CommentOwner.WEBSITE_ANNO, website);
return commentOwner;
}
}

View File

@ -0,0 +1,85 @@
package run.halo.app.content.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.MultiValueMap;
import run.halo.app.extension.router.IListRequest;
/**
* Query criteria for comment list.
*
* @author guqing
* @since 2.0.0
*/
public class CommentQuery extends IListRequest.QueryListRequest {
public CommentQuery(MultiValueMap<String, String> queryParams) {
super(queryParams);
}
@Schema(description = "Comments filtered by keyword.")
public String getKeyword() {
String keyword = queryParams.getFirst("keyword");
return StringUtils.isBlank(keyword) ? null : keyword;
}
@Schema(description = "Comments approved.")
public Boolean getApproved() {
return convertBooleanOrNull(queryParams.getFirst("approved"));
}
@Schema(description = "The comment is hidden from the theme side.")
public Boolean getHidden() {
return convertBooleanOrNull(queryParams.getFirst("hidden"));
}
@Schema(description = "Send notifications when there are new replies.")
public Boolean getAllowNotification() {
return convertBooleanOrNull(queryParams.getFirst("allowNotification"));
}
@Schema(description = "Comment top display.")
public Boolean getTop() {
return convertBooleanOrNull(queryParams.getFirst("top"));
}
@Schema(description = "Commenter kind.")
public String getOwnerKind() {
String ownerKind = queryParams.getFirst("ownerKind");
return StringUtils.isBlank(ownerKind) ? null : ownerKind;
}
@Schema(description = "Commenter name.")
public String getOwnerName() {
String ownerName = queryParams.getFirst("ownerName");
return StringUtils.isBlank(ownerName) ? null : ownerName;
}
@Schema(description = "Comment subject kind.")
public String getSubjectKind() {
String subjectKind = queryParams.getFirst("subjectKind");
return StringUtils.isBlank(subjectKind) ? null : subjectKind;
}
@Schema(description = "Comment subject name.")
public String getSubjectName() {
String subjectName = queryParams.getFirst("subjectName");
return StringUtils.isBlank(subjectName) ? null : subjectName;
}
@Schema(description = "Comment collation.")
public CommentSorter getSort() {
String sort = queryParams.getFirst("sort");
return CommentSorter.convertFrom(sort);
}
@Schema(description = "ascending order If it is true; otherwise, it is in descending order.")
public Boolean getSortOrder() {
String sortOrder = queryParams.getFirst("sortOrder");
return convertBooleanOrNull(sortOrder);
}
private Boolean convertBooleanOrNull(String value) {
return StringUtils.isBlank(value) ? null : Boolean.parseBoolean(value);
}
}

View File

@ -0,0 +1,55 @@
package run.halo.app.content.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
import lombok.Data;
import run.halo.app.core.extension.Comment;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Ref;
/**
* Request parameter object for {@link Comment}.
*
* @author guqing
* @since 2.0.0
*/
@Data
public class CommentRequest {
@Schema(required = true)
private Ref subjectRef;
private CommentEmailOwner owner;
@Schema(required = true, minLength = 1)
private String raw;
@Schema(required = true, minLength = 1)
private String content;
@Schema(defaultValue = "false")
private Boolean allowNotification;
/**
* Converts {@link CommentRequest} to {@link Comment}.
*
* @return a comment
*/
public Comment toComment() {
Comment comment = new Comment();
comment.setMetadata(new Metadata());
comment.getMetadata().setName(UUID.randomUUID().toString());
Comment.CommentSpec spec = new Comment.CommentSpec();
comment.setSpec(spec);
spec.setSubjectRef(subjectRef);
spec.setRaw(raw);
spec.setContent(content);
spec.setAllowNotification(allowNotification);
if (owner != null) {
spec.setOwner(owner.toCommentOwner());
}
return comment;
}
}

View File

@ -0,0 +1,18 @@
package run.halo.app.content.comment;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Comment;
import run.halo.app.extension.ListResult;
/**
* An application service for {@link Comment}.
*
* @author guqing
* @since 2.0.0
*/
public interface CommentService {
Mono<ListResult<ListedComment>> listComment(CommentQuery query);
Mono<Comment> create(Comment comment);
}

View File

@ -0,0 +1,230 @@
package run.halo.app.content.comment;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import java.util.Comparator;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.User;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.plugin.ExtensionComponentsFinder;
/**
* Comment service implementation.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class CommentServiceImpl implements CommentService {
private final ReactiveExtensionClient client;
private final ExtensionComponentsFinder extensionComponentsFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
public CommentServiceImpl(ReactiveExtensionClient client,
ExtensionComponentsFinder extensionComponentsFinder,
SystemConfigurableEnvironmentFetcher environmentFetcher) {
this.client = client;
this.extensionComponentsFinder = extensionComponentsFinder;
this.environmentFetcher = environmentFetcher;
}
@Override
public Mono<ListResult<ListedComment>> listComment(CommentQuery commentQuery) {
Comparator<Comment> comparator =
CommentSorter.from(commentQuery.getSort(), commentQuery.getSortOrder());
return this.client.list(Comment.class, commentPredicate(commentQuery),
comparator,
commentQuery.getPage(), commentQuery.getSize())
.flatMap(comments -> Flux.fromStream(comments.get()
.map(this::toListedComment))
.flatMap(Function.identity())
.collectList()
.map(list -> new ListResult<>(comments.getPage(), comments.getSize(),
comments.getTotal(), list)
)
);
}
@Override
public Mono<Comment> create(Comment comment) {
return environmentFetcher.fetchComment()
.flatMap(commentSetting -> {
if (Boolean.FALSE.equals(commentSetting.getEnable())) {
return Mono.error(
new AccessDeniedException("The comment function has been turned off."));
}
if (comment.getSpec().getTop() == null) {
comment.getSpec().setTop(false);
}
if (comment.getSpec().getPriority() == null) {
comment.getSpec().setPriority(0);
}
comment.getSpec()
.setApproved(Boolean.FALSE.equals(commentSetting.getRequireReviewForNew()));
comment.getSpec().setHidden(comment.getSpec().getApproved());
if (comment.getSpec().getOwner() != null) {
return Mono.just(comment);
}
// populate owner from current user
return fetchCurrentUser()
.map(this::toCommentOwner)
.map(owner -> {
comment.getSpec().setOwner(owner);
return comment;
})
.switchIfEmpty(
Mono.error(new IllegalStateException("The owner must not be null.")));
})
.flatMap(client::create);
}
private Comment.CommentOwner toCommentOwner(User user) {
Comment.CommentOwner owner = new Comment.CommentOwner();
owner.setKind(User.KIND);
owner.setName(user.getMetadata().getName());
owner.setDisplayName(user.getSpec().getDisplayName());
return owner;
}
private Mono<User> fetchCurrentUser() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> securityContext.getAuthentication().getName())
.flatMap(username -> client.fetch(User.class, username));
}
private Mono<ListedComment> toListedComment(Comment comment) {
ListedComment.ListedCommentBuilder commentBuilder = ListedComment.builder()
.comment(comment);
return Mono.just(commentBuilder)
.flatMap(builder -> {
Comment.CommentOwner owner = comment.getSpec().getOwner();
// not empty
return getCommentOwnerInfo(owner)
.map(builder::owner);
})
.flatMap(builder -> getCommentSubject(comment.getSpec().getSubjectRef())
.map(subject -> {
builder.subject(subject);
return builder;
})
)
.map(ListedComment.ListedCommentBuilder::build);
}
private Mono<OwnerInfo> getCommentOwnerInfo(Comment.CommentOwner owner) {
if (User.KIND.equals(owner.getKind())) {
return client.fetch(User.class, owner.getName())
.map(OwnerInfo::from)
.switchIfEmpty(Mono.just(OwnerInfo.ghostUser()));
}
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
return Mono.just(OwnerInfo.from(owner));
}
throw new IllegalStateException(
"Unsupported owner kind: " + owner.getKind());
}
@SuppressWarnings("unchecked")
Mono<Extension> getCommentSubject(Ref ref) {
return extensionComponentsFinder.getExtensions(CommentSubject.class)
.stream()
.filter(commentSubject -> commentSubject.supports(ref))
.findFirst()
.map(commentSubject -> commentSubject.get(ref.getName()))
.orElseGet(Mono::empty);
}
Predicate<Comment> commentPredicate(CommentQuery query) {
Predicate<Comment> predicate = comment -> true;
String keyword = query.getKeyword();
if (keyword != null) {
predicate = predicate.and(comment -> {
String raw = comment.getSpec().getRaw();
return StringUtils.containsIgnoreCase(raw, keyword);
});
}
Boolean approved = query.getApproved();
if (approved != null) {
predicate =
predicate.and(comment -> Objects.equals(comment.getSpec().getApproved(), approved));
}
Boolean hidden = query.getHidden();
if (hidden != null) {
predicate =
predicate.and(comment -> Objects.equals(comment.getSpec().getHidden(), hidden));
}
Boolean top = query.getTop();
if (top != null) {
predicate = predicate.and(comment -> Objects.equals(comment.getSpec().getTop(), top));
}
Boolean allowNotification = query.getAllowNotification();
if (allowNotification != null) {
predicate = predicate.and(
comment -> Objects.equals(comment.getSpec().getAllowNotification(),
allowNotification));
}
String ownerKind = query.getOwnerKind();
if (ownerKind != null) {
predicate = predicate.and(comment -> {
Comment.CommentOwner owner = comment.getSpec().getOwner();
return Objects.equals(owner.getKind(), ownerKind);
});
}
String ownerName = query.getOwnerName();
if (ownerName != null) {
predicate = predicate.and(comment -> {
Comment.CommentOwner owner = comment.getSpec().getOwner();
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
return Objects.equals(owner.getKind(), ownerKind)
&& (StringUtils.containsIgnoreCase(owner.getName(), ownerName)
|| StringUtils.containsIgnoreCase(owner.getDisplayName(), ownerName));
}
return Objects.equals(owner.getKind(), ownerKind)
&& StringUtils.containsIgnoreCase(owner.getName(), ownerName);
});
}
String subjectKind = query.getSubjectKind();
if (subjectKind != null) {
predicate = predicate.and(comment -> {
Ref subjectRef = comment.getSpec().getSubjectRef();
return Objects.equals(subjectRef.getKind(), subjectKind);
});
}
String subjectName = query.getSubjectName();
if (subjectName != null) {
predicate = predicate.and(comment -> {
Ref subjectRef = comment.getSpec().getSubjectRef();
return Objects.equals(subjectRef.getKind(), subjectKind)
&& StringUtils.containsIgnoreCase(subjectRef.getName(), subjectName);
});
}
Predicate<Extension> labelAndFieldSelectorPredicate =
labelAndFieldSelectorToPredicate(query.getLabelSelector(),
query.getFieldSelector());
return predicate.and(labelAndFieldSelectorPredicate);
}
}

View File

@ -0,0 +1,73 @@
package run.halo.app.content.comment;
import java.time.Instant;
import java.util.Comparator;
import java.util.Objects;
import java.util.function.Function;
import org.springframework.util.comparator.Comparators;
import run.halo.app.core.extension.Comment;
/**
* Comment sorter.
*
* @author guqing
* @since 2.0.0
*/
public enum CommentSorter {
LAST_REPLY_TIME,
REPLY_COUNT,
CREATE_TIME;
static final Function<Comment, String> name = comment -> comment.getMetadata().getName();
static Comparator<Comment> from(CommentSorter sorter, Boolean ascending) {
if (Objects.equals(true, ascending)) {
return from(sorter);
}
return from(sorter).reversed();
}
static Comparator<Comment> from(CommentSorter sorter) {
if (sorter == null) {
return defaultCommentComparator();
}
if (CREATE_TIME.equals(sorter)) {
Function<Comment, Instant> comparatorFunc =
comment -> comment.getMetadata().getCreationTimestamp();
return Comparator.comparing(comparatorFunc)
.thenComparing(name);
}
if (REPLY_COUNT.equals(sorter)) {
Function<Comment, Integer> comparatorFunc =
comment -> comment.getStatusOrDefault().getReplyCount();
return Comparator.comparing(comparatorFunc, Comparators.nullsLow())
.thenComparing(name);
}
if (LAST_REPLY_TIME.equals(sorter)) {
Function<Comment, Instant> comparatorFunc =
comment -> comment.getStatusOrDefault().getLastReplyTime();
return Comparator.comparing(comparatorFunc, Comparators.nullsLow())
.thenComparing(name);
}
throw new IllegalStateException("Unsupported sort value: " + sorter);
}
static CommentSorter convertFrom(String sort) {
for (CommentSorter sorter : values()) {
if (sorter.name().equalsIgnoreCase(sort)) {
return sorter;
}
}
return null;
}
static Comparator<Comment> defaultCommentComparator() {
Function<Comment, Instant> lastReplyTime =
comment -> comment.getStatusOrDefault().getLastReplyTime();
return Comparator.comparing(lastReplyTime, Comparators.nullsLow())
.thenComparing(name);
}
}

View File

@ -0,0 +1,19 @@
package run.halo.app.content.comment;
import org.pf4j.ExtensionPoint;
import reactor.core.publisher.Mono;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.Ref;
/**
* Comment subject.
*
* @author guqing
* @since 2.0.0
*/
public interface CommentSubject<T extends AbstractExtension> extends ExtensionPoint {
Mono<T> get(String name);
boolean supports(Ref ref);
}

View File

@ -0,0 +1,26 @@
package run.halo.app.content.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Value;
import run.halo.app.core.extension.Comment;
import run.halo.app.extension.Extension;
/**
* Listed comment.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
public class ListedComment {
@Schema(required = true)
Comment comment;
@Schema(required = true)
OwnerInfo owner;
Extension subject;
}

View File

@ -0,0 +1,23 @@
package run.halo.app.content.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Value;
import run.halo.app.core.extension.Reply;
/**
* Listed reply for {@link Reply}.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
public class ListedReply {
@Schema(required = true)
Reply reply;
@Schema(required = true)
OwnerInfo owner;
}

View File

@ -0,0 +1,77 @@
package run.halo.app.content.comment;
import lombok.Builder;
import lombok.Value;
import org.apache.commons.lang3.StringUtils;
import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.User;
/**
* Comment owner info.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
public class OwnerInfo {
String kind;
String name;
String displayName;
String avatar;
String email;
/**
* Convert user to owner info by owner that has an email kind .
*
* @param owner comment owner reference.
* @return owner info.
*/
public static OwnerInfo from(Comment.CommentOwner owner) {
if (!Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
throw new IllegalArgumentException("Only support 'email' owner kind.");
}
return OwnerInfo.builder()
.kind(owner.getKind())
.name(owner.getName())
.email(owner.getName())
.displayName(owner.getDisplayName())
.avatar(owner.getAnnotation(Comment.CommentOwner.AVATAR_ANNO))
.build();
}
/**
* Convert user to owner info by {@link User}.
*
* @param user user extension.
* @return owner info.
*/
public static OwnerInfo from(User user) {
return OwnerInfo.builder()
.kind(user.getKind())
.name(user.getMetadata().getName())
.email(user.getSpec().getEmail())
.avatar(user.getSpec().getAvatar())
.displayName(user.getSpec().getDisplayName())
.build();
}
/**
* Obtain a ghost owner info when user not found.
*
* @return a ghost user if user not found.
*/
public static OwnerInfo ghostUser() {
return OwnerInfo.builder()
.kind(User.KIND)
.name("ghost")
.email(StringUtils.EMPTY)
.displayName("Ghost")
.build();
}
}

View File

@ -0,0 +1,38 @@
package run.halo.app.content.comment;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Post;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
/**
* Comment subject for post.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class PostCommentSubject implements CommentSubject<Post> {
private final ReactiveExtensionClient client;
public PostCommentSubject(ReactiveExtensionClient client) {
this.client = client;
}
@Override
public Mono<Post> get(String name) {
return client.fetch(Post.class, name);
}
@Override
public boolean supports(Ref ref) {
Assert.notNull(ref, "Subject ref must not be null.");
GroupVersionKind groupVersionKind =
new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
return GroupVersionKind.fromExtension(Post.class).equals(groupVersionKind);
}
}

View File

@ -0,0 +1,25 @@
package run.halo.app.content.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.MultiValueMap;
import run.halo.app.extension.router.IListRequest;
/**
* Query criteria for {@link run.halo.app.core.extension.Reply} list.
*
* @author guqing
* @since 2.0.0
*/
public class ReplyQuery extends IListRequest.QueryListRequest {
public ReplyQuery(MultiValueMap<String, String> queryParams) {
super(queryParams);
}
@Schema(description = "Replies filtered by commentName.")
public String getCommentName() {
String commentName = queryParams.getFirst("commentName");
return StringUtils.isBlank(commentName) ? null : commentName;
}
}

View File

@ -0,0 +1,53 @@
package run.halo.app.content.comment;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
import lombok.Data;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.Metadata;
/**
* A request parameter object for {@link Reply}.
*
* @author guqing
* @since 2.0.0
*/
@Data
public class ReplyRequest {
@Schema(required = true, minLength = 1)
private String raw;
@Schema(required = true, minLength = 1)
private String content;
@Schema(defaultValue = "false")
private Boolean allowNotification;
private CommentEmailOwner owner;
private String quoteReply;
/**
* Converts {@link ReplyRequest} to {@link Reply}.
*
* @return a reply
*/
public Reply toReply() {
Reply reply = new Reply();
reply.setMetadata(new Metadata());
reply.getMetadata().setName(UUID.randomUUID().toString());
Reply.ReplySpec spec = new Reply.ReplySpec();
reply.setSpec(spec);
spec.setRaw(raw);
spec.setContent(content);
spec.setAllowNotification(allowNotification);
spec.setQuoteReply(quoteReply);
if (owner != null) {
spec.setOwner(owner.toCommentOwner());
}
return reply;
}
}

View File

@ -0,0 +1,18 @@
package run.halo.app.content.comment;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.ListResult;
/**
* An application service for {@link Reply}.
*
* @author guqing
* @since 2.0.0
*/
public interface ReplyService {
Mono<Reply> create(String commentName, Reply reply);
Mono<ListResult<ListedReply>> list(ReplyQuery query);
}

View File

@ -0,0 +1,155 @@
package run.halo.app.content.comment;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import java.time.Instant;
import java.util.Comparator;
import java.util.function.Function;
import java.util.function.Predicate;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Reply;
import run.halo.app.core.extension.User;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.exception.AccessDeniedException;
/**
* A default implementation of {@link ReplyService}.
*
* @author guqing
* @since 2.0.0
*/
@Service
public class ReplyServiceImpl implements ReplyService {
private final ReactiveExtensionClient client;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
public ReplyServiceImpl(ReactiveExtensionClient client,
SystemConfigurableEnvironmentFetcher environmentFetcher) {
this.client = client;
this.environmentFetcher = environmentFetcher;
}
@Override
public Mono<Reply> create(String commentName, Reply reply) {
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
// Boolean allowNotification = reply.getSpec().getAllowNotification();
// TODO send notification if allowNotification is true
reply.getSpec().setCommentName(commentName);
if (reply.getSpec().getTop() == null) {
reply.getSpec().setTop(false);
}
if (reply.getSpec().getPriority() == null) {
reply.getSpec().setPriority(0);
}
return environmentFetcher.fetchComment()
.map(commentSetting -> {
if (Boolean.FALSE.equals(commentSetting.getEnable())) {
throw new AccessDeniedException(
"The comment function has been turned off.");
}
reply.getSpec().setApproved(
Boolean.FALSE.equals(commentSetting.getRequireReviewForNew()));
reply.getSpec().setHidden(reply.getSpec().getApproved());
return reply;
});
})
.flatMap(replyToUse -> {
if (replyToUse.getSpec().getOwner() != null) {
return Mono.just(replyToUse);
}
// populate owner from current user
return fetchCurrentUser()
.map(user -> {
replyToUse.getSpec().setOwner(toCommentOwner(user));
return replyToUse;
})
.switchIfEmpty(
Mono.error(new IllegalStateException("Reply owner must not be null.")));
})
.flatMap(client::create)
.switchIfEmpty(Mono.error(
new IllegalArgumentException(
String.format("Comment not found for name [%s].", commentName)))
);
}
@Override
public Mono<ListResult<ListedReply>> list(ReplyQuery query) {
return client.list(Reply.class, getReplyPredicate(query), defaultComparator(),
query.getPage(), query.getSize())
.flatMap(list -> Flux.fromStream(list.get()
.map(this::toListedReply))
.flatMap(Function.identity())
.collectList()
.map(listedReplies -> new ListResult<>(list.getPage(), list.getSize(),
list.getTotal(), listedReplies))
);
}
private Mono<ListedReply> toListedReply(Reply reply) {
ListedReply.ListedReplyBuilder builder = ListedReply.builder()
.reply(reply);
return getOwnerInfo(reply)
.map(ownerInfo -> {
builder.owner(ownerInfo);
return builder;
})
.map(ListedReply.ListedReplyBuilder::build);
}
private Mono<OwnerInfo> getOwnerInfo(Reply reply) {
Comment.CommentOwner owner = reply.getSpec().getOwner();
if (User.KIND.equals(owner.getKind())) {
return client.fetch(User.class, owner.getName())
.map(OwnerInfo::from)
.switchIfEmpty(Mono.just(OwnerInfo.ghostUser()));
}
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
return Mono.just(OwnerInfo.from(owner));
}
throw new IllegalStateException(
"Unsupported owner kind: " + owner.getKind());
}
Comparator<Reply> defaultComparator() {
Function<Reply, Instant> createTime = reply -> reply.getMetadata().getCreationTimestamp();
return Comparator.comparing(createTime)
.thenComparing(reply -> reply.getMetadata().getName());
}
Predicate<Reply> getReplyPredicate(ReplyQuery query) {
Predicate<Reply> predicate = reply -> true;
if (query.getCommentName() != null) {
predicate = predicate.and(
reply -> query.getCommentName().equals(reply.getSpec().getCommentName()));
}
Predicate<Extension> labelAndFieldSelectorPredicate =
labelAndFieldSelectorToPredicate(query.getLabelSelector(),
query.getFieldSelector());
return predicate.and(labelAndFieldSelectorPredicate);
}
private Comment.CommentOwner toCommentOwner(User user) {
Comment.CommentOwner owner = new Comment.CommentOwner();
owner.setKind(User.KIND);
owner.setName(user.getMetadata().getName());
owner.setDisplayName(user.getSpec().getDisplayName());
return owner;
}
private Mono<User> fetchCurrentUser() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> securityContext.getAuthentication().getName())
.flatMap(username -> client.fetch(User.class, username));
}
}

View File

@ -0,0 +1,38 @@
package run.halo.app.content.comment;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
/**
* Comment subject for {@link SinglePage}.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class SinglePageCommentSubject implements CommentSubject<SinglePage> {
private final ReactiveExtensionClient client;
public SinglePageCommentSubject(ReactiveExtensionClient client) {
this.client = client;
}
@Override
public Mono<SinglePage> get(String name) {
return client.fetch(SinglePage.class, name);
}
@Override
public boolean supports(Ref ref) {
Assert.notNull(ref, "Subject ref must not be null.");
GroupVersionKind groupVersionKind =
new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
return GroupVersionKind.fromExtension(SinglePage.class).equals(groupVersionKind);
}
}

View File

@ -1,12 +1,16 @@
package run.halo.app.core.extension;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.Map;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.lang.Nullable;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.extension.Ref;
/**
* @author guqing
@ -24,12 +28,25 @@ public class Comment extends AbstractExtension {
@Schema(required = true)
private CommentSpec spec;
@Schema
private CommentStatus status;
@JsonIgnore
public CommentStatus getStatusOrDefault() {
if (this.status == null) {
this.status = new CommentStatus();
}
return this.status;
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class CommentSpec extends BaseCommentSpec {
@Schema(required = true)
private CommentSubjectRef subjectRef;
private Ref subjectRef;
private Instant lastReadTime;
}
@Data
@ -66,23 +83,38 @@ public class Comment extends AbstractExtension {
@Data
public static class CommentOwner {
public static final String KIND_EMAIL = "Email";
public static final String AVATAR_ANNO = "avatar";
public static final String WEBSITE_ANNO = "website";
@Schema(required = true, minLength = 1)
private String kind;
@Schema(required = true, minLength = 1, maxLength = 64)
@Schema(required = true, maxLength = 64)
private String name;
private String displayName;
private Map<String, String> annotations;
@Nullable
@JsonIgnore
public String getAnnotation(String key) {
return annotations == null ? null : annotations.get(key);
}
}
@Data
public static class CommentSubjectRef {
@Schema(required = true, minLength = 1)
private String kind;
public static class CommentStatus {
@Schema(required = true, minLength = 1, maxLength = 64)
private String name;
private Instant lastReplyTime;
private Integer replyCount;
private Integer unreadReplyCount;
public boolean getHasNewReply() {
return unreadReplyCount != null && unreadReplyCount > 0;
}
}
}

View File

@ -0,0 +1,137 @@
package run.halo.app.core.extension.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.content.comment.CommentQuery;
import run.halo.app.content.comment.CommentRequest;
import run.halo.app.content.comment.CommentService;
import run.halo.app.content.comment.ListedComment;
import run.halo.app.content.comment.ReplyRequest;
import run.halo.app.content.comment.ReplyService;
import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.router.QueryParamBuildUtil;
import run.halo.app.infra.utils.IpAddressUtils;
/**
* Endpoint for managing comment.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class CommentEndpoint implements CustomEndpoint {
private final CommentService commentService;
private final ReplyService replyService;
public CommentEndpoint(CommentService commentService, ReplyService replyService) {
this.commentService = commentService;
this.replyService = replyService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.halo.run/v1alpha1/Comment";
return SpringdocRouteBuilder.route()
.GET("comments", this::listComments, builder -> {
builder.operationId("ListComments")
.description("List comments.")
.tag(tag)
.response(responseBuilder()
.implementation(ListResult.generateGenericClass(ListedComment.class))
);
QueryParamBuildUtil.buildParametersFromType(builder, CommentQuery.class);
}
)
.POST("comments", this::createComment,
builder -> builder.operationId("CreateComment")
.description("Create a comment.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(CommentRequest.class))
))
.response(responseBuilder()
.implementation(Comment.class))
)
.POST("comments/{name}/reply", this::createReply,
builder -> builder.operationId("CreateReply")
.description("Create a reply.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(ReplyRequest.class))
))
.response(responseBuilder()
.implementation(Reply.class))
)
.build();
}
Mono<ServerResponse> listComments(ServerRequest request) {
CommentQuery commentQuery = new CommentQuery(request.queryParams());
return commentService.listComment(commentQuery)
.flatMap(listedComments -> ServerResponse.ok().bodyValue(listedComments));
}
Mono<ServerResponse> createComment(ServerRequest request) {
return request.bodyToMono(CommentRequest.class)
.flatMap(commentRequest -> {
Comment comment = commentRequest.toComment();
comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
comment.getSpec().setUserAgent(userAgentFrom(request));
return commentService.create(comment);
})
.flatMap(comment -> ServerResponse.ok().bodyValue(comment));
}
Mono<ServerResponse> createReply(ServerRequest request) {
String commentName = request.pathVariable("name");
return request.bodyToMono(ReplyRequest.class)
.flatMap(replyRequest -> {
Reply reply = replyRequest.toReply();
reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
reply.getSpec().setUserAgent(userAgentFrom(request));
return replyService.create(commentName, reply);
})
.flatMap(comment -> ServerResponse.ok().bodyValue(comment));
}
private String userAgentFrom(ServerRequest request) {
HttpHeaders httpHeaders = request.headers().asHttpHeaders();
// https://en.wikipedia.org/wiki/User_agent
String userAgent = httpHeaders.getFirst(HttpHeaders.USER_AGENT);
if (StringUtils.isBlank(userAgent)) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA
userAgent = httpHeaders.getFirst("Sec-CH-UA");
}
return StringUtils.defaultString(userAgent, "UNKNOWN");
}
}

View File

@ -0,0 +1,55 @@
package run.halo.app.core.extension.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.content.comment.ListedReply;
import run.halo.app.content.comment.ReplyQuery;
import run.halo.app.content.comment.ReplyService;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.router.QueryParamBuildUtil;
/**
* Endpoint for managing {@link Reply}.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class ReplyEndpoint implements CustomEndpoint {
private final ReplyService replyService;
public ReplyEndpoint(ReplyService replyService) {
this.replyService = replyService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.halo.run/v1alpha1/Reply";
return SpringdocRouteBuilder.route()
.GET("replies", this::listReplies, builder -> {
builder.operationId("ListReplies")
.description("List replies.")
.tag(tag)
.response(responseBuilder()
.implementation(ListResult.generateGenericClass(ListedReply.class))
);
QueryParamBuildUtil.buildParametersFromType(builder, ReplyQuery.class);
}
)
.build();
}
Mono<ServerResponse> listReplies(ServerRequest request) {
ReplyQuery replyQuery = new ReplyQuery(request.queryParams());
return replyService.list(replyQuery)
.flatMap(listedReplies -> ServerResponse.ok().bodyValue(listedReplies));
}
}

View File

@ -0,0 +1,122 @@
package run.halo.app.core.extension.reconciler;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
/**
* Reconciler for {@link Comment}.
*
* @author guqing
* @since 2.0.0
*/
public class CommentReconciler implements Reconciler<Reconciler.Request> {
public static final String FINALIZER_NAME = "comment-protection";
private final ExtensionClient client;
public CommentReconciler(ExtensionClient client) {
this.client = client;
}
@Override
public Result reconcile(Request request) {
return client.fetch(Comment.class, request.name())
.map(comment -> {
if (isDeleted(comment)) {
cleanUpResourcesAndRemoveFinalizer(request.name());
return new Result(false, null);
}
addFinalizerIfNecessary(comment);
reconcileStatus(request.name());
return new Result(true, Duration.ofMinutes(1));
})
.orElseGet(() -> new Result(false, null));
}
private boolean isDeleted(Comment comment) {
return comment.getMetadata().getDeletionTimestamp() != null;
}
private void reconcileStatus(String name) {
client.fetch(Comment.class, name).ifPresent(comment -> {
final Comment oldComment = JsonUtils.deepCopy(comment);
List<Reply> replies = client.list(Reply.class,
reply -> name.equals(reply.getSpec().getCommentName()),
defaultReplyComparator());
// calculate reply count
comment.getStatusOrDefault().setReplyCount(replies.size());
// calculate last reply time
if (!replies.isEmpty()) {
Instant lastReplyTime = replies.get(0).getMetadata().getCreationTimestamp();
comment.getStatusOrDefault().setLastReplyTime(lastReplyTime);
}
// calculate unread reply count
Instant lastReadTime = comment.getSpec().getLastReadTime();
long unreadReplyCount = replies.stream()
.filter(reply -> {
if (lastReadTime == null) {
return true;
}
return reply.getMetadata().getCreationTimestamp().isAfter(lastReadTime);
})
.count();
comment.getStatusOrDefault().setUnreadReplyCount((int) unreadReplyCount);
if (!oldComment.equals(comment)) {
client.update(comment);
}
});
}
private void addFinalizerIfNecessary(Comment oldComment) {
Set<String> finalizers = oldComment.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(Comment.class, oldComment.getMetadata().getName())
.ifPresent(comment -> {
Set<String> newFinalizers = comment.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
comment.getMetadata().setFinalizers(newFinalizers);
}
newFinalizers.add(FINALIZER_NAME);
client.update(comment);
});
}
private void cleanUpResourcesAndRemoveFinalizer(String commentName) {
client.fetch(Comment.class, commentName).ifPresent(comment -> {
cleanUpResources(comment);
if (comment.getMetadata().getFinalizers() != null) {
comment.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
client.update(comment);
});
}
private void cleanUpResources(Comment comment) {
// delete all replies under current comment
client.list(Reply.class, reply -> comment.getMetadata().getName()
.equals(reply.getSpec().getCommentName()),
null)
.forEach(client::delete);
}
Comparator<Reply> defaultReplyComparator() {
Function<Reply, Instant> createTime = reply -> reply.getMetadata().getCreationTimestamp();
return Comparator.comparing(createTime)
.thenComparing(reply -> reply.getMetadata().getName())
.reversed();
}
}

View File

@ -25,7 +25,9 @@ public class SystemConfigurableEnvironmentFetcher {
}
public <T> Mono<T> fetch(String key, Class<T> type) {
return getValuesInternal().map(map -> map.get(key))
return getValuesInternal()
.filter(map -> map.containsKey(key))
.map(map -> map.get(key))
.mapNotNull(stringValue -> {
if (conversionService.canConvert(String.class, type)) {
return conversionService.convert(stringValue, type);
@ -34,6 +36,11 @@ public class SystemConfigurableEnvironmentFetcher {
});
}
public Mono<SystemSetting.Comment> fetchComment() {
return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class)
.switchIfEmpty(Mono.just(new SystemSetting.Comment()));
}
@NonNull
private Mono<Map<String, String>> getValuesInternal() {
return getConfigMap()

View File

@ -0,0 +1,72 @@
package run.halo.app.infra.utils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.server.ServerRequest;
/**
* Ip address utils.
* Code from internet.
*/
public class IpAddressUtils {
private static final String UNKNOWN = "unknown";
private static final String X_REAL_IP = "X-Real-IP";
private static final String X_FORWARDED_FOR = "X-Forwarded-For";
private static final String PROXY_CLIENT_IP = "Proxy-Client-IP";
private static final String WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP";
private static final String HTTP_CLIENT_IP = "HTTP_CLIENT_IP";
private static final String HTTP_X_FORWARDED_FOR = "HTTP_X_FORWARDED_FOR";
/**
* Gets the ip address from request.
*
* @param request http request
* @return ip address if found, otherwise {@link #UNKNOWN}.
*/
public static String getIpAddress(ServerRequest request) {
try {
return getIpAddressInternal(request);
} catch (Exception e) {
return UNKNOWN;
}
}
private static String getIpAddressInternal(ServerRequest request) {
HttpHeaders httpHeaders = request.headers().asHttpHeaders();
String xrealIp = httpHeaders.getFirst(X_REAL_IP);
String xforwardedFor = httpHeaders.getFirst(X_FORWARDED_FOR);
if (StringUtils.isNotEmpty(xforwardedFor) && !UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
// After multiple reverse proxies, there will be multiple IP values. The first IP is
// the real IP
int index = xforwardedFor.indexOf(",");
if (index != -1) {
return xforwardedFor.substring(0, index);
} else {
return xforwardedFor;
}
}
xforwardedFor = xrealIp;
if (StringUtils.isNotEmpty(xforwardedFor) && !UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
return xforwardedFor;
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(PROXY_CLIENT_IP);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(WL_PROXY_CLIENT_IP);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(HTTP_CLIENT_IP);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(HTTP_X_FORWARDED_FOR);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = request.remoteAddress()
.map(remoteAddress -> remoteAddress.getAddress().getHostAddress())
.orElse(UNKNOWN);
}
return xforwardedFor;
}
}

View File

@ -0,0 +1,66 @@
package run.halo.app.plugin;
import java.util.ArrayList;
import java.util.List;
import org.pf4j.ExtensionPoint;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
/**
* Extension components finder for {@link ExtensionPoint}.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class ExtensionComponentsFinder {
public static final String SYSTEM_PLUGIN_ID = "system";
private final HaloPluginManager haloPluginManager;
private final ApplicationContext applicationContext;
public ExtensionComponentsFinder(HaloPluginManager haloPluginManager,
ApplicationContext applicationContext) {
this.haloPluginManager = haloPluginManager;
this.applicationContext = applicationContext;
}
/**
* Finds all extension components.
*
* @param type subclass type of extension point
* @param <T> extension component type
* @return extension components
*/
public <T> List<T> getExtensions(Class<T> type) {
assertExtensionPoint(type);
List<T> components = new ArrayList<>(haloPluginManager.getExtensions(type));
components.addAll(applicationContext.getBeansOfType(type).values());
return List.copyOf(components);
}
/**
* <p>Finds all extension components by plugin id.</p>
* If the plugin id is system or null, it means to find from halo.
*
* @param type subclass type of extension point
* @param <T> extension component type
* @return extension components
*/
public <T> List<T> getExtensions(Class<T> type, String pluginId) {
assertExtensionPoint(type);
List<T> components = new ArrayList<>();
if (pluginId == null || SYSTEM_PLUGIN_ID.equals(pluginId)) {
components.addAll(applicationContext.getBeansOfType(type).values());
return components;
} else {
components.addAll(haloPluginManager.getExtensions(type, pluginId));
}
return components;
}
private void assertExtensionPoint(Class<?> type) {
if (!ExtensionPoint.class.isAssignableFrom(type)) {
throw new IllegalArgumentException("The type must be a subclass of ExtensionPoint");
}
}
}

View File

@ -3,6 +3,7 @@ package run.halo.app.theme.finders;
import org.springframework.lang.Nullable;
import run.halo.app.core.extension.Comment;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Ref;
import run.halo.app.theme.finders.vo.CommentVo;
import run.halo.app.theme.finders.vo.ReplyVo;
@ -16,7 +17,7 @@ public interface CommentFinder {
CommentVo getByName(String name);
ListResult<CommentVo> list(Comment.CommentSubjectRef ref, @Nullable Integer page,
ListResult<CommentVo> list(Ref ref, @Nullable Integer page,
@Nullable Integer size);
ListResult<ReplyVo> listReply(String commentName, @Nullable Integer page,

View File

@ -12,6 +12,7 @@ import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
import run.halo.app.theme.finders.CommentFinder;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.vo.CommentVo;
@ -40,7 +41,7 @@ public class CommentFinderImpl implements CommentFinder {
}
@Override
public ListResult<CommentVo> list(Comment.CommentSubjectRef ref, Integer page, Integer size) {
public ListResult<CommentVo> list(Ref ref, Integer page, Integer size) {
return client.list(Comment.class, fixedPredicate(ref),
defaultComparator(),
pageNullSafe(page), sizeNullSafe(size))
@ -69,9 +70,9 @@ public class CommentFinderImpl implements CommentFinder {
.block();
}
private Predicate<Comment> fixedPredicate(Comment.CommentSubjectRef ref) {
private Predicate<Comment> fixedPredicate(Ref ref) {
Assert.notNull(ref, "Comment subject reference must not be null");
return comment -> ref.equals(comment.getSpec().getSubjectRef())
return comment -> comment.getSpec().getSubjectRef().equals(ref)
&& Objects.equals(false, comment.getSpec().getHidden())
&& Objects.equals(true, comment.getSpec().getApproved());
}

View File

@ -5,4 +5,7 @@ metadata:
labels:
halo.run/role-template: "true"
halo.run/hidden: "true"
rules: [ ]
rules:
- apiGroups: [ "api.halo.run" ]
resources: [ "comments", "comments/reply" ]
verbs: [ "create" ]

View File

@ -0,0 +1,52 @@
package run.halo.app.content.comment;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.Comment;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link CommentEmailOwner}.
*
* @author guqing
* @since 2.0.0
*/
class CommentEmailOwnerTest {
@Test
void constructorTest() throws JSONException {
CommentEmailOwner commentEmailOwner =
new CommentEmailOwner("example@example.com", "avatar", "displayName", "website");
JSONAssert.assertEquals("""
{
"email": "example@example.com",
"avatar": "avatar",
"displayName": "displayName",
"website": "website"
}
""",
JsonUtils.objectToJson(commentEmailOwner),
true);
}
@Test
void toCommentOwner() throws JSONException {
CommentEmailOwner commentEmailOwner =
new CommentEmailOwner("example@example.com", "avatar", "displayName", "website");
Comment.CommentOwner commentOwner = commentEmailOwner.toCommentOwner();
JSONAssert.assertEquals("""
{
"kind": "Email",
"name": "example@example.com",
"displayName": "displayName",
"annotations": {
"website": "website",
"avatar": "avatar"
}
}
""",
JsonUtils.objectToJson(commentOwner),
true);
}
}

View File

@ -0,0 +1,203 @@
package run.halo.app.content.comment;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
/**
* Tests for {@link CommentQuery}.
*
* @author guqing
* @since 2.0.0
*/
class CommentQueryTest {
@Test
void getKeyword() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("keyword", "test");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getKeyword()).isEqualTo("test");
queryParams.clear();
queryParams.add("keyword", "");
assertThat(commentQuery.getKeyword()).isNull();
queryParams.clear();
queryParams.add("keyword", null);
assertThat(commentQuery.getKeyword()).isNull();
queryParams.clear();
}
@Test
void getApproved() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("approved", "true");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getApproved()).isTrue();
queryParams.clear();
queryParams.add("approved", "");
assertThat(commentQuery.getApproved()).isNull();
queryParams.clear();
queryParams.add("approved", "1");
assertThat(commentQuery.getApproved()).isFalse();
queryParams.clear();
}
@Test
void getHidden() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("hidden", "true");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getHidden()).isTrue();
queryParams.clear();
queryParams.add("hidden", "");
assertThat(commentQuery.getHidden()).isNull();
queryParams.clear();
queryParams.add("hidden", "1");
assertThat(commentQuery.getHidden()).isFalse();
queryParams.clear();
}
@Test
void getAllowNotification() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("allowNotification", "true");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getAllowNotification()).isTrue();
queryParams.clear();
queryParams.add("allowNotification", "");
assertThat(commentQuery.getAllowNotification()).isNull();
queryParams.clear();
queryParams.add("allowNotification", "1");
assertThat(commentQuery.getAllowNotification()).isFalse();
queryParams.clear();
}
@Test
void getTop() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("top", "true");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getTop()).isTrue();
queryParams.clear();
queryParams.add("top", "");
assertThat(commentQuery.getTop()).isNull();
queryParams.clear();
queryParams.add("top", "1");
assertThat(commentQuery.getTop()).isFalse();
queryParams.clear();
}
@Test
void getOwnerKind() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("ownerKind", "test-owner-kind");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getOwnerKind()).isEqualTo("test-owner-kind");
queryParams.clear();
queryParams.add("ownerKind", "");
assertThat(commentQuery.getOwnerKind()).isNull();
queryParams.clear();
queryParams.add("ownerKind", null);
assertThat(commentQuery.getOwnerKind()).isNull();
queryParams.clear();
}
@Test
void getOwnerName() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("ownerName", "test-owner-name");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getOwnerName()).isEqualTo("test-owner-name");
queryParams.clear();
queryParams.add("ownerName", "");
assertThat(commentQuery.getOwnerName()).isNull();
queryParams.clear();
queryParams.add("ownerName", null);
assertThat(commentQuery.getOwnerName()).isNull();
queryParams.clear();
}
@Test
void getSubjectKind() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("subjectKind", "test-subject-kind");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getSubjectKind()).isEqualTo("test-subject-kind");
queryParams.clear();
queryParams.add("subjectKind", "");
assertThat(commentQuery.getSubjectKind()).isNull();
queryParams.clear();
queryParams.add("subjectKind", null);
assertThat(commentQuery.getSubjectKind()).isNull();
queryParams.clear();
}
@Test
void getSubjectName() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("subjectName", "test-subject-name");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getSubjectName()).isEqualTo("test-subject-name");
queryParams.clear();
queryParams.add("subjectName", "");
assertThat(commentQuery.getSubjectName()).isNull();
queryParams.clear();
queryParams.add("subjectName", null);
assertThat(commentQuery.getSubjectName()).isNull();
queryParams.clear();
}
@Test
void getSort() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("sort", CommentSorter.REPLY_COUNT.name());
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getSort()).isEqualTo(CommentSorter.REPLY_COUNT);
queryParams.clear();
queryParams.add("sort", "");
assertThat(commentQuery.getSort()).isNull();
queryParams.clear();
queryParams.add("sort", "nothing");
assertThat(commentQuery.getSort()).isNull();
queryParams.clear();
}
@Test
void getSortOrder() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("sortOrder", "true");
CommentQuery commentQuery = new CommentQuery(queryParams);
assertThat(commentQuery.getSortOrder()).isTrue();
queryParams.clear();
queryParams.add("sortOrder", "");
assertThat(commentQuery.getSortOrder()).isNull();
queryParams.clear();
queryParams.add("sortOrder", null);
assertThat(commentQuery.getSortOrder()).isNull();
queryParams.clear();
}
}

View File

@ -0,0 +1,86 @@
package run.halo.app.content.comment;
import static org.assertj.core.api.Assertions.assertThat;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.Comment;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Ref;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link CommentRequest}.
*
* @author guqing
* @since 2.0.0
*/
class CommentRequestTest {
@Test
void constructor() throws JSONException {
CommentRequest commentRequest = createCommentRequest();
JSONAssert.assertEquals("""
{
"subjectRef": {
"group": "fake.halo.run",
"version": "v1alpha1",
"kind": "Fake",
"name": "fake"
},
"raw": "raw",
"content": "content",
"allowNotification": true
}
""",
JsonUtils.objectToJson(commentRequest),
true);
}
@Test
void toComment() throws JSONException {
CommentRequest commentRequest = createCommentRequest();
Comment comment = commentRequest.toComment();
assertThat(comment.getMetadata().getName()).isNotNull();
comment.getMetadata().setName("fake");
JSONAssert.assertEquals("""
{
"spec": {
"raw": "raw",
"content": "content",
"allowNotification": true,
"subjectRef": {
"group": "fake.halo.run",
"version": "v1alpha1",
"kind": "Fake",
"name": "fake"
}
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Comment",
"metadata": {
"name": "fake"
}
}
""",
JsonUtils.objectToJson(comment),
true);
}
private static CommentRequest createCommentRequest() {
CommentRequest commentRequest = new CommentRequest();
commentRequest.setRaw("raw");
commentRequest.setContent("content");
commentRequest.setAllowNotification(true);
FakeExtension fakeExtension = new FakeExtension();
fakeExtension.setMetadata(new Metadata());
fakeExtension.getMetadata().setName("fake");
commentRequest.setSubjectRef(Ref.of(fakeExtension));
return commentRequest;
}
}

View File

@ -0,0 +1,394 @@
package run.halo.app.content.comment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import org.json.JSONException;
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.Mockito;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.plugin.ExtensionComponentsFinder;
/**
* Tests for {@link CommentServiceImpl}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(SpringExtension.class)
class CommentServiceImplTest {
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;
@Mock
private ReactiveExtensionClient client;
@Mock
private ExtensionComponentsFinder extensionComponentsFinder;
@InjectMocks
private CommentServiceImpl commentService;
@BeforeEach
void setUp() {
SystemSetting.Comment commentSetting = getCommentSetting();
lenient().when(environmentFetcher.fetchComment()).thenReturn(Mono.just(commentSetting));
ListResult<Comment> comments = new ListResult<>(1, 10, 3, comments());
when(client.list(eq(Comment.class), any(), any(), anyInt(), anyInt()))
.thenReturn(Mono.just(comments));
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName("B-owner");
user.setSpec(new User.UserSpec());
user.getSpec().setAvatar("B-avatar");
user.getSpec().setDisplayName("B-displayName");
user.getSpec().setEmail("B-email");
when(client.fetch(eq(User.class), eq("B-owner")))
.thenReturn(Mono.just(user));
when(client.fetch(eq(User.class), eq("C-owner")))
.thenReturn(Mono.empty());
PostCommentSubject postCommentSubject = Mockito.mock(PostCommentSubject.class);
when(extensionComponentsFinder.getExtensions(eq(CommentSubject.class)))
.thenReturn(List.of(postCommentSubject));
when(postCommentSubject.supports(any())).thenReturn(true);
when(postCommentSubject.get(eq("fake-post"))).thenReturn(Mono.just(post()));
}
@Test
void listComment() {
Mono<ListResult<ListedComment>> listResultMono =
commentService.listComment(new CommentQuery(new LinkedMultiValueMap<>()));
StepVerifier.create(listResultMono)
.consumeNextWith(result -> {
try {
JSONAssert.assertEquals(expectListResultJson(),
JsonUtils.objectToJson(result),
true);
} catch (JSONException e) {
throw new RuntimeException(e);
}
})
.verifyComplete();
}
@Test
@WithMockUser(username = "B-owner")
void create() throws JSONException {
CommentRequest commentRequest = new CommentRequest();
commentRequest.setRaw("fake-raw");
commentRequest.setContent("fake-content");
commentRequest.setAllowNotification(true);
commentRequest.setSubjectRef(Ref.of(post()));
ArgumentCaptor<Comment> captor = ArgumentCaptor.forClass(Comment.class);
Comment commentToCreate = commentRequest.toComment();
commentToCreate.getMetadata().setName("fake");
Mono<Comment> commentMono = commentService.create(commentToCreate);
when(client.create(any())).thenReturn(Mono.empty());
StepVerifier.create(commentMono)
.verifyComplete();
verify(client, times(1)).create(captor.capture());
Comment comment = captor.getValue();
JSONAssert.assertEquals("""
{
"spec": {
"raw": "fake-raw",
"content": "fake-content",
"owner": {
"kind": "User",
"name": "B-owner",
"displayName": "B-displayName"
},
"priority": 0,
"top": false,
"allowNotification": true,
"approved": false,
"hidden": false,
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",
"kind": "Post",
"name": "fake-post"
}
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Comment",
"metadata": {
"name": "fake"
}
}
""",
JsonUtils.objectToJson(comment),
true);
}
@Test
void commentPredicate() {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("keyword", "hello");
queryParams.add("approved", "true");
queryParams.add("hidden", "false");
queryParams.add("allowNotification", "true");
queryParams.add("top", "false");
queryParams.add("ownerKind", "User");
queryParams.add("ownerName", "fake-user");
queryParams.add("subjectKind", "Post");
queryParams.add("subjectName", "fake-post");
final Predicate<Comment> predicate =
commentService.commentPredicate(new CommentQuery(queryParams));
Comment comment = comment("A");
comment.getSpec().setRaw("hello-world");
comment.getSpec().setApproved(true);
comment.getSpec().setHidden(false);
comment.getSpec().setAllowNotification(true);
comment.getSpec().setTop(false);
Comment.CommentOwner commentOwner = new Comment.CommentOwner();
commentOwner.setKind("User");
commentOwner.setName("fake-user");
commentOwner.setDisplayName("fake-user-display-name");
comment.getSpec().setOwner(commentOwner);
comment.getSpec().setSubjectRef(Ref.of(post()));
assertThat(predicate.test(comment)).isTrue();
queryParams.remove("keyword");
queryParams.add("keyword", "nothing");
final Predicate<Comment> predicateTwo =
commentService.commentPredicate(new CommentQuery(queryParams));
assertThat(predicateTwo.test(comment)).isFalse();
}
private List<Comment> comments() {
Comment a = comment("A");
a.getSpec().getOwner().setKind(Comment.CommentOwner.KIND_EMAIL);
a.getSpec().getOwner()
.setAnnotations(Map.of(Comment.CommentOwner.AVATAR_ANNO, "avatar",
Comment.CommentOwner.WEBSITE_ANNO, "website"));
return List.of(a, comment("B"), comment("C"));
}
private Comment comment(String name) {
Comment comment = new Comment();
comment.setMetadata(new Metadata());
comment.getMetadata().setName(name);
comment.setSpec(new Comment.CommentSpec());
Comment.CommentOwner commentOwner = new Comment.CommentOwner();
commentOwner.setKind(User.KIND);
commentOwner.setDisplayName("displayName");
commentOwner.setName(name + "-owner");
comment.getSpec().setOwner(commentOwner);
comment.getSpec().setSubjectRef(Ref.of(post()));
comment.setStatus(new Comment.CommentStatus());
return comment;
}
private Post post() {
Post post = TestPost.postV1();
post.getMetadata().setName("fake-post");
return post;
}
private static SystemSetting.Comment getCommentSetting() {
SystemSetting.Comment commentSetting = new SystemSetting.Comment();
commentSetting.setEnable(true);
commentSetting.setSystemUserOnly(true);
commentSetting.setRequireReviewForNew(true);
return commentSetting;
}
private String expectListResultJson() {
return """
{
"page": 1,
"size": 10,
"total": 3,
"items": [
{
"comment": {
"spec": {
"owner": {
"kind": "Email",
"name": "A-owner",
"displayName": "displayName",
"annotations": {
"website": "website",
"avatar": "avatar"
}
},
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",
"kind": "Post",
"name": "fake-post"
}
},
"status": {
"hasNewReply": false
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Comment",
"metadata": {
"name": "A"
}
},
"owner": {
"kind": "Email",
"name": "A-owner",
"displayName": "displayName",
"avatar": "avatar",
"email": "A-owner"
},
"subject": {
"spec": {
"title": "post-A",
"headSnapshot": "base-snapshot",
"baseSnapshot": "snapshot-A",
"version": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {
"name": "fake-post"
}
}
},
{
"comment": {
"spec": {
"owner": {
"kind": "User",
"name": "B-owner",
"displayName": "displayName"
},
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",
"kind": "Post",
"name": "fake-post"
}
},
"status": {
"hasNewReply": false
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Comment",
"metadata": {
"name": "B"
}
},
"owner": {
"kind": "User",
"name": "B-owner",
"displayName": "B-displayName",
"avatar": "B-avatar",
"email": "B-email"
},
"subject": {
"spec": {
"title": "post-A",
"headSnapshot": "base-snapshot",
"baseSnapshot": "snapshot-A",
"version": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {
"name": "fake-post"
}
}
},
{
"comment": {
"spec": {
"owner": {
"kind": "User",
"name": "C-owner",
"displayName": "displayName"
},
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",
"kind": "Post",
"name": "fake-post"
}
},
"status": {
"hasNewReply": false
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Comment",
"metadata": {
"name": "C"
}
},
"owner": {
"kind": "User",
"name": "ghost",
"displayName": "Ghost",
"email": ""
},
"subject": {
"spec": {
"title": "post-A",
"headSnapshot": "base-snapshot",
"baseSnapshot": "snapshot-A",
"version": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {
"name": "fake-post"
}
}
}
],
"first": true,
"last": true,
"hasNext": false,
"hasPrevious": false
}
""";
}
}

View File

@ -0,0 +1,139 @@
package run.halo.app.content.comment;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import org.junit.jupiter.api.Test;
import run.halo.app.core.extension.Comment;
import run.halo.app.extension.Metadata;
/**
* Tests for {@link CommentSorter}.
*
* @author guqing
* @since 2.0.0
*/
class CommentSorterTest {
@Test
void sortByCreateTimeAsc() {
Comparator<Comment> createTimeSorter = CommentSorter.from(CommentSorter.CREATE_TIME);
List<String> commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("B", "C", "A"));
createTimeSorter = CommentSorter.from(CommentSorter.CREATE_TIME, true);
commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("B", "C", "A"));
}
@Test
void sortByCreateTimeDesc() {
Comparator<Comment> createTimeSorter = CommentSorter.from(CommentSorter.CREATE_TIME, false);
List<String> commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("A", "C", "B"));
}
@Test
void sortByReplyCountAsc() {
Comparator<Comment> createTimeSorter = CommentSorter.from(CommentSorter.REPLY_COUNT);
List<String> commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("A", "B", "C"));
createTimeSorter = CommentSorter.from(CommentSorter.REPLY_COUNT, true);
commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("A", "B", "C"));
}
@Test
void sortByReplyCountDesc() {
Comparator<Comment> createTimeSorter = CommentSorter.from(CommentSorter.REPLY_COUNT, false);
List<String> commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("C", "B", "A"));
}
@Test
void sortByLastReplyTimeAsc() {
Comparator<Comment> createTimeSorter = CommentSorter.from(CommentSorter.LAST_REPLY_TIME);
List<String> commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("C", "A", "B"));
createTimeSorter = CommentSorter.from(CommentSorter.LAST_REPLY_TIME, true);
commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("C", "A", "B"));
}
@Test
void sortByLastReplyTimeDesc() {
Comparator<Comment> createTimeSorter =
CommentSorter.from(CommentSorter.LAST_REPLY_TIME, false);
List<String> commentNames = comments().stream()
.sorted(createTimeSorter)
.map(comment -> comment.getMetadata().getName())
.toList();
assertThat(commentNames).isEqualTo(List.of("B", "A", "C"));
}
List<Comment> comments() {
final Instant now = Instant.now();
Comment commentA = new Comment();
commentA.setMetadata(new Metadata());
commentA.getMetadata().setName("A");
// create time
commentA.getMetadata().setCreationTimestamp(now.plusSeconds(10));
commentA.setSpec(new Comment.CommentSpec());
commentA.setStatus(new Comment.CommentStatus());
// last reply time
commentA.getStatus().setLastReplyTime(now.plusSeconds(5));
// reply count
commentA.getStatus().setReplyCount(3);
Comment commentB = new Comment();
commentB.setMetadata(new Metadata());
commentB.getMetadata().setName("B");
commentB.getMetadata().setCreationTimestamp(now.plusSeconds(5));
commentB.setSpec(new Comment.CommentSpec());
commentB.setStatus(new Comment.CommentStatus());
commentB.getStatus().setLastReplyTime(now.plusSeconds(15));
commentB.getStatus().setReplyCount(8);
Comment commentC = new Comment();
commentC.setMetadata(new Metadata());
commentC.getMetadata().setName("C");
commentC.getMetadata().setCreationTimestamp(now.plusSeconds(5));
commentC.setSpec(new Comment.CommentSpec());
commentC.setStatus(new Comment.CommentStatus());
commentC.getStatus().setLastReplyTime(now.plusSeconds(3));
commentC.getStatus().setReplyCount(10);
return List.of(commentA, commentB, commentC);
}
}

View File

@ -0,0 +1,67 @@
package run.halo.app.content.comment;
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.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.Post;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
/**
* Tests for {@link PostCommentSubject}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class PostCommentSubjectTest {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private PostCommentSubject postCommentSubject;
@Test
void get() {
when(client.fetch(eq(Post.class), any()))
.thenReturn(Mono.empty());
when(client.fetch(eq(Post.class), eq("fake-post")))
.thenReturn(Mono.just(TestPost.postV1()));
postCommentSubject.get("fake-post")
.as(StepVerifier::create)
.expectNext(TestPost.postV1())
.verifyComplete();
postCommentSubject.get("fake-post2")
.as(StepVerifier::create)
.verifyComplete();
}
@Test
void supports() {
Post post = new Post();
post.setMetadata(new Metadata());
post.getMetadata().setName("test");
boolean supports = postCommentSubject.supports(Ref.of(post));
assertThat(supports).isTrue();
FakeExtension fakeExtension = new FakeExtension();
fakeExtension.setMetadata(new Metadata());
fakeExtension.getMetadata().setName("test");
supports = postCommentSubject.supports(Ref.of(fakeExtension));
assertThat(supports).isFalse();
}
}

View File

@ -0,0 +1,75 @@
package run.halo.app.content.comment;
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 org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
/**
* Tests for {@link SinglePageCommentSubject}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class SinglePageCommentSubjectTest {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private SinglePageCommentSubject singlePageCommentSubject;
@Test
void get() {
when(client.fetch(eq(SinglePage.class), any()))
.thenReturn(Mono.empty());
SinglePage singlePage = new SinglePage();
singlePage.setMetadata(new Metadata());
singlePage.getMetadata().setName("fake-single-page");
when(client.fetch(eq(SinglePage.class), eq("fake-single-page")))
.thenReturn(Mono.just(singlePage));
singlePageCommentSubject.get("fake-single-page")
.as(StepVerifier::create)
.expectNext(singlePage)
.verifyComplete();
singlePageCommentSubject.get("fake-single-page-2")
.as(StepVerifier::create)
.verifyComplete();
verify(client, times(1)).fetch(eq(SinglePage.class), eq("fake-single-page"));
}
@Test
void supports() {
SinglePage singlePage = new SinglePage();
singlePage.setMetadata(new Metadata());
singlePage.getMetadata().setName("test");
boolean supports = singlePageCommentSubject.supports(Ref.of(singlePage));
assertThat(supports).isTrue();
FakeExtension fakeExtension = new FakeExtension();
fakeExtension.setMetadata(new Metadata());
fakeExtension.getMetadata().setName("test");
supports = singlePageCommentSubject.supports(Ref.of(fakeExtension));
assertThat(supports).isFalse();
}
}

View File

@ -0,0 +1,132 @@
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.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
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.core.extension.Comment;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
/**
* Tests for {@link CommentReconciler}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class CommentReconcilerTest {
@Mock
private ExtensionClient client;
@InjectMocks
private CommentReconciler commentReconciler;
private final Instant now = Instant.now();
@Test
void reconcile() {
Comment comment = new Comment();
comment.setMetadata(new Metadata());
comment.getMetadata().setName("test");
comment.setSpec(new Comment.CommentSpec());
comment.getSpec().setLastReadTime(now.plusSeconds(5));
comment.setStatus(new Comment.CommentStatus());
lenient().when(client.fetch(eq(Comment.class), eq("test")))
.thenReturn(Optional.of(comment));
lenient().when(client.list(eq(Reply.class), any(), any()))
.thenReturn(replyList());
Reconciler.Result result = commentReconciler.reconcile(new Reconciler.Request("test"));
assertThat(result.reEnqueue()).isTrue();
assertThat(result.retryAfter()).isEqualTo(Duration.ofMinutes(1));
verify(client, times(3)).fetch(eq(Comment.class), eq("test"));
verify(client, times(1)).list(eq(Reply.class), any(), any());
ArgumentCaptor<Comment> captor = ArgumentCaptor.forClass(Comment.class);
verify(client, times(2)).update(captor.capture());
List<Comment> allValues = captor.getAllValues();
Comment value = allValues.get(1);
assertThat(value.getStatus().getReplyCount()).isEqualTo(3);
assertThat(value.getStatus().getLastReplyTime()).isEqualTo(now.plusSeconds(6));
assertThat(value.getStatus().getUnreadReplyCount()).isEqualTo(1);
assertThat(value.getStatus().getHasNewReply()).isTrue();
assertThat(value.getMetadata().getFinalizers()).contains(CommentReconciler.FINALIZER_NAME);
}
@Test
void reconcileDelete() {
Comment comment = new Comment();
comment.setMetadata(new Metadata());
comment.getMetadata().setName("test");
comment.getMetadata().setDeletionTimestamp(Instant.now());
Set<String> finalizers = new HashSet<>();
finalizers.add(CommentReconciler.FINALIZER_NAME);
comment.getMetadata().setFinalizers(finalizers);
comment.setSpec(new Comment.CommentSpec());
comment.getSpec().setLastReadTime(now.plusSeconds(5));
comment.setStatus(new Comment.CommentStatus());
lenient().when(client.list(eq(Reply.class), any(), any()))
.thenReturn(replyList());
lenient().when(client.fetch(eq(Comment.class), eq("test")))
.thenReturn(Optional.of(comment));
lenient().when(client.list(eq(Reply.class), any(), any()))
.thenReturn(replyList());
Reconciler.Result reconcile = commentReconciler.reconcile(new Reconciler.Request("test"));
assertThat(reconcile.reEnqueue()).isFalse();
assertThat(reconcile.retryAfter()).isNull();
verify(client, times(1)).list(eq(Reply.class), any(), any());
verify(client, times(3)).delete(any(Reply.class));
ArgumentCaptor<Comment> captor = ArgumentCaptor.forClass(Comment.class);
verify(client, times(1)).update(captor.capture());
Comment value = captor.getValue();
assertThat(value.getMetadata().getFinalizers()
.contains(CommentReconciler.FINALIZER_NAME)).isFalse();
}
List<Reply> replyList() {
Reply replyA = new Reply();
replyA.setMetadata(new Metadata());
replyA.getMetadata().setName("reply-A");
replyA.getMetadata().setCreationTimestamp(now.plusSeconds(6));
Reply replyB = new Reply();
replyB.setMetadata(new Metadata());
replyB.getMetadata().setName("reply-B");
replyB.getMetadata().setCreationTimestamp(now.plusSeconds(5));
Reply replyC = new Reply();
replyC.setMetadata(new Metadata());
replyC.getMetadata().setName("reply-C");
replyC.getMetadata().setCreationTimestamp(now.plusSeconds(4));
return List.of(replyA, replyB, replyC);
}
}