mirror of https://github.com/halo-dev/halo
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
parent
510f155e05
commit
9fdc9c1bb7
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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" ]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue