> list(ReplyQuery query) {
return client.list(Reply.class, getReplyPredicate(query), defaultComparator(),
diff --git a/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java
index 0a7b9d69c..39bb7afb8 100644
--- a/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java
+++ b/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java
@@ -124,7 +124,13 @@ public class CommentEndpoint implements CustomEndpoint {
.flatMap(comment -> ServerResponse.ok().bodyValue(comment));
}
- private String userAgentFrom(ServerRequest request) {
+ /**
+ * Gets user-agent from server request.
+ *
+ * @param request server request
+ * @return user-agent string if found, otherwise "unknown"
+ */
+ public static String userAgentFrom(ServerRequest request) {
HttpHeaders httpHeaders = request.headers().asHttpHeaders();
// https://en.wikipedia.org/wiki/User_agent
String userAgent = httpHeaders.getFirst(HttpHeaders.USER_AGENT);
@@ -132,6 +138,6 @@ public class CommentEndpoint implements CustomEndpoint {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA
userAgent = httpHeaders.getFirst("Sec-CH-UA");
}
- return StringUtils.defaultString(userAgent, "UNKNOWN");
+ return StringUtils.defaultString(userAgent, "unknown");
}
}
diff --git a/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java b/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java
new file mode 100644
index 000000000..7f01467a0
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java
@@ -0,0 +1,58 @@
+package run.halo.app.theme.dialect;
+
+import java.util.List;
+import org.springframework.context.ApplicationContext;
+import org.thymeleaf.context.ITemplateContext;
+import org.thymeleaf.model.IProcessableElementTag;
+import org.thymeleaf.processor.element.AbstractElementTagProcessor;
+import org.thymeleaf.processor.element.IElementTagStructureHandler;
+import org.thymeleaf.spring6.context.SpringContextUtils;
+import org.thymeleaf.templatemode.TemplateMode;
+import run.halo.app.plugin.ExtensionComponentsFinder;
+
+
+/**
+ * Comment element tag processor.
+ * Replace the comment tag <halo:comment />
with the given content.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public class CommentElementTagProcessor extends AbstractElementTagProcessor {
+
+ private static final String TAG_NAME = "comment";
+
+ private static final int PRECEDENCE = 1000;
+
+ /**
+ * Constructor footer element tag processor with HTML mode, dialect prefix, comment tag name.
+ *
+ * @param dialectPrefix dialect prefix
+ */
+ public CommentElementTagProcessor(final String dialectPrefix) {
+ super(
+ TemplateMode.HTML, // This processor will apply only to HTML mode
+ dialectPrefix, // Prefix to be applied to name for matching
+ TAG_NAME, // Tag name: match specifically this tag
+ true, // Apply dialect prefix to tag name
+ null, // No attribute name: will match by tag name
+ false, // No prefix to be applied to attribute name
+ PRECEDENCE); // Precedence (inside dialect's own precedence)
+ }
+
+ @Override
+ protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
+ IElementTagStructureHandler structureHandler) {
+ final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context);
+ ExtensionComponentsFinder componentsFinder =
+ appCtx.getBean(ExtensionComponentsFinder.class);
+ List commentWidgets = componentsFinder.getExtensions(CommentWidget.class);
+ if (commentWidgets.isEmpty()) {
+ structureHandler.replaceWith("", false);
+ return;
+ }
+ // TODO if find more than one comment widget, query CommentWidget setting to decide which
+ // one to use.
+ commentWidgets.get(0).render(context, tag, structureHandler);
+ }
+}
diff --git a/src/main/java/run/halo/app/theme/dialect/CommentWidget.java b/src/main/java/run/halo/app/theme/dialect/CommentWidget.java
new file mode 100644
index 000000000..21214115e
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/dialect/CommentWidget.java
@@ -0,0 +1,18 @@
+package run.halo.app.theme.dialect;
+
+import org.pf4j.ExtensionPoint;
+import org.thymeleaf.context.ITemplateContext;
+import org.thymeleaf.model.IProcessableElementTag;
+import org.thymeleaf.processor.element.IElementTagStructureHandler;
+
+/**
+ * Comment widget extension point to extend the <halo:comment /> tag of the theme-side.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public interface CommentWidget extends ExtensionPoint {
+
+ void render(ITemplateContext context, IProcessableElementTag tag,
+ IElementTagStructureHandler structureHandler);
+}
diff --git a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java
index 8aea154e2..171b7ddf8 100644
--- a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java
+++ b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java
@@ -28,6 +28,7 @@ public class HaloProcessorDialect extends AbstractProcessorDialect {
processors.add(new GlobalHeadInjectionProcessor(dialectPrefix));
processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
processors.add(new JsonNodePropertyAccessorBoundariesProcessor());
+ processors.add(new CommentElementTagProcessor(dialectPrefix));
return processors;
}
}
diff --git a/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java b/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java
new file mode 100644
index 000000000..b1f953c48
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java
@@ -0,0 +1,268 @@
+package run.halo.app.theme.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 com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+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.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.MultiValueMap;
+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 reactor.core.scheduler.Schedulers;
+import run.halo.app.content.comment.CommentRequest;
+import run.halo.app.content.comment.CommentService;
+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.core.extension.endpoint.CommentEndpoint;
+import run.halo.app.core.extension.endpoint.CustomEndpoint;
+import run.halo.app.extension.GroupVersion;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.Ref;
+import run.halo.app.extension.router.IListRequest;
+import run.halo.app.extension.router.QueryParamBuildUtil;
+import run.halo.app.infra.utils.IpAddressUtils;
+import run.halo.app.theme.finders.CommentFinder;
+import run.halo.app.theme.finders.vo.CommentVo;
+import run.halo.app.theme.finders.vo.ReplyVo;
+
+/**
+ * Endpoint for {@link CommentFinder}.
+ */
+@Component
+public class CommentFinderEndpoint implements CustomEndpoint {
+
+ private final CommentFinder commentFinder;
+ private final CommentService commentService;
+ private final ReplyService replyService;
+
+ /**
+ * Construct a {@link CommentFinderEndpoint} instance.
+ *
+ * @param commentFinder comment finder
+ * @param commentService comment service to create comment
+ * @param replyService reply service to create reply
+ */
+ public CommentFinderEndpoint(CommentFinder commentFinder, CommentService commentService,
+ ReplyService replyService) {
+ this.commentFinder = commentFinder;
+ this.commentService = commentService;
+ this.replyService = replyService;
+ }
+
+ @Override
+ public RouterFunction endpoint() {
+ final var tag = "api.halo.run/v1alpha1/Comment";
+ return SpringdocRouteBuilder.route()
+ .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))
+ )
+ .GET("comments", this::listComments, builder -> {
+ builder.operationId("ListComments")
+ .description("List comments.")
+ .tag(tag)
+ .response(responseBuilder()
+ .implementation(ListResult.generateGenericClass(CommentVo.class))
+ );
+ QueryParamBuildUtil.buildParametersFromType(builder, CommentQuery.class);
+ })
+ .GET("comments/{name}", this::getComment, builder -> {
+ builder.operationId("GetComment")
+ .description("Get a comment.")
+ .tag(tag)
+ .parameter(parameterBuilder().name("name")
+ .in(ParameterIn.PATH)
+ .required(true)
+ .implementation(String.class))
+ .response(responseBuilder()
+ .implementation(ListResult.generateGenericClass(CommentVo.class))
+ );
+ })
+ .GET("comments/{name}/reply", this::listCommentReplies, builder -> {
+ builder.operationId("ListCommentReplies")
+ .description("List comment replies.")
+ .tag(tag)
+ .parameter(parameterBuilder().name("name")
+ .in(ParameterIn.PATH)
+ .required(true)
+ .implementation(String.class))
+ .response(responseBuilder()
+ .implementation(ListResult.generateGenericClass(ReplyVo.class))
+ );
+ QueryParamBuildUtil.buildParametersFromType(builder, PageableRequest.class);
+ })
+ .build();
+ }
+
+ @Override
+ public GroupVersion groupVersion() {
+ return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1");
+ }
+
+ Mono createComment(ServerRequest request) {
+ return request.bodyToMono(CommentRequest.class)
+ .flatMap(commentRequest -> {
+ Comment comment = commentRequest.toComment();
+ comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
+ comment.getSpec().setUserAgent(CommentEndpoint.userAgentFrom(request));
+ return commentService.create(comment);
+ })
+ .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
+ }
+
+ Mono 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(CommentEndpoint.userAgentFrom(request));
+ return replyService.create(commentName, reply);
+ })
+ .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
+ }
+
+ Mono listComments(ServerRequest request) {
+ CommentQuery commentQuery = new CommentQuery(request.queryParams());
+ return Mono.defer(() -> Mono.just(
+ commentFinder.list(commentQuery.toRef(), commentQuery.getPage(),
+ commentQuery.getSize())))
+ .subscribeOn(Schedulers.boundedElastic())
+ .flatMap(list -> ServerResponse.ok().bodyValue(list));
+ }
+
+ Mono getComment(ServerRequest request) {
+ String name = request.pathVariable("name");
+ return Mono.defer(() -> Mono.justOrEmpty(commentFinder.getByName(name)))
+ .subscribeOn(Schedulers.boundedElastic())
+ .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
+ }
+
+ Mono listCommentReplies(ServerRequest request) {
+ String commentName = request.pathVariable("name");
+ IListRequest.QueryListRequest queryParams =
+ new IListRequest.QueryListRequest(request.queryParams());
+ return Mono.defer(() -> Mono.just(
+ commentFinder.listReply(commentName, queryParams.getPage(), queryParams.getSize())))
+ .subscribeOn(Schedulers.boundedElastic())
+ .flatMap(list -> ServerResponse.ok().bodyValue(list));
+ }
+
+ public static class CommentQuery extends PageableRequest {
+
+ public CommentQuery(MultiValueMap queryParams) {
+ super(queryParams);
+ }
+
+ @Schema(description = "The comment subject group.")
+ public String getGroup() {
+ return queryParams.getFirst("group");
+ }
+
+ @Schema(required = true, description = "The comment subject version.")
+ public String getVersion() {
+ return emptyToNull(queryParams.getFirst("version"));
+ }
+
+ /**
+ * Gets the {@link Ref}s kind.
+ *
+ * @return comment subject ref kind
+ */
+ @Schema(required = true, description = "The comment subject kind.")
+ public String getKind() {
+ String kind = emptyToNull(queryParams.getFirst("kind"));
+ if (kind == null) {
+ throw new IllegalArgumentException("The kind must not be null.");
+ }
+ return kind;
+ }
+
+ /**
+ * Gets the {@link Ref}s name.
+ *
+ * @return comment subject ref name
+ */
+ @Schema(required = true, description = "The comment subject name.")
+ public String getName() {
+ String name = emptyToNull(queryParams.getFirst("name"));
+ if (name == null) {
+ throw new IllegalArgumentException("The name must not be null.");
+ }
+ return name;
+ }
+
+ Ref toRef() {
+ Ref ref = new Ref();
+ ref.setGroup(getGroup());
+ ref.setKind(getKind());
+ ref.setVersion(getVersion());
+ ref.setName(getName());
+ return ref;
+ }
+
+ String emptyToNull(String str) {
+ return StringUtils.isBlank(str) ? null : str;
+ }
+ }
+
+ public static class PageableRequest extends IListRequest.QueryListRequest {
+
+ public PageableRequest(MultiValueMap queryParams) {
+ super(queryParams);
+ }
+
+ @Override
+ @JsonIgnore
+ public List getLabelSelector() {
+ throw new UnsupportedOperationException("Unsupported this parameter");
+ }
+
+ @Override
+ @JsonIgnore
+ public List getFieldSelector() {
+ throw new UnsupportedOperationException("Unsupported this parameter");
+ }
+ }
+}
diff --git a/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java
index 6f73fcf57..f891e03fe 100644
--- a/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java
+++ b/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java
@@ -2,14 +2,17 @@ package run.halo.app.theme.finders.impl;
import java.time.Instant;
import java.util.Comparator;
-import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.util.Assert;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.comment.OwnerInfo;
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.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
@@ -36,7 +39,7 @@ public class CommentFinderImpl implements CommentFinder {
@Override
public CommentVo getByName(String name) {
return client.fetch(Comment.class, name)
- .map(CommentVo::from)
+ .flatMap(this::toCommentVo)
.block();
}
@@ -45,11 +48,13 @@ public class CommentFinderImpl implements CommentFinder {
return client.list(Comment.class, fixedPredicate(ref),
defaultComparator(),
pageNullSafe(page), sizeNullSafe(size))
- .map(list -> {
- List commentVos = list.get().map(CommentVo::from).toList();
- return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
- commentVos);
- })
+ .flatMap(list -> Flux.fromStream(list.get().map(this::toCommentVo))
+ .flatMap(Function.identity())
+ .collectList()
+ .map(commentVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
+ commentVos)
+ )
+ )
.block();
}
@@ -62,14 +67,39 @@ public class CommentFinderImpl implements CommentFinder {
&& Objects.equals(false, reply.getSpec().getHidden())
&& Objects.equals(true, reply.getSpec().getApproved()),
comparator.reversed(), pageNullSafe(page), sizeNullSafe(size))
- .map(list -> {
- List replyVos = list.get().map(ReplyVo::from).toList();
- return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
- replyVos);
- })
+ .flatMap(list -> Flux.fromStream(list.get().map(this::toReplyVo))
+ .flatMap(Function.identity())
+ .collectList()
+ .map(replyVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
+ replyVos))
+ )
.block();
}
+ private Mono toCommentVo(Comment comment) {
+ Comment.CommentOwner owner = comment.getSpec().getOwner();
+ return Mono.just(CommentVo.from(comment))
+ .flatMap(commentVo -> getOwnerInfo(owner)
+ .map(commentVo::withOwner)
+ );
+ }
+
+ private Mono toReplyVo(Reply reply) {
+ return Mono.just(ReplyVo.from(reply))
+ .flatMap(replyVo -> getOwnerInfo(reply.getSpec().getOwner())
+ .map(replyVo::withOwner)
+ );
+ }
+
+ private Mono getOwnerInfo(Comment.CommentOwner owner) {
+ if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
+ return Mono.just(OwnerInfo.from(owner));
+ }
+ return client.fetch(User.class, owner.getName())
+ .map(OwnerInfo::from)
+ .switchIfEmpty(Mono.just(OwnerInfo.ghostUser()));
+ }
+
private Predicate fixedPredicate(Ref ref) {
Assert.notNull(ref, "Comment subject reference must not be null");
return comment -> comment.getSpec().getSubjectRef().equals(ref)
diff --git a/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java b/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java
index f0f5a27df..4210ec426 100644
--- a/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java
+++ b/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java
@@ -1,8 +1,11 @@
package run.halo.app.theme.finders.vo;
+import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Value;
+import lombok.With;
+import run.halo.app.content.comment.OwnerInfo;
import run.halo.app.core.extension.Comment;
import run.halo.app.extension.MetadataOperator;
@@ -17,10 +20,18 @@ import run.halo.app.extension.MetadataOperator;
@EqualsAndHashCode
public class CommentVo {
+ @Schema(required = true)
MetadataOperator metadata;
+ @Schema(required = true)
Comment.CommentSpec spec;
+ Comment.CommentStatus status;
+
+ @With
+ @Schema(required = true)
+ OwnerInfo owner;
+
/**
* Convert {@link Comment} to {@link CommentVo}.
*
@@ -31,6 +42,7 @@ public class CommentVo {
return CommentVo.builder()
.metadata(comment.getMetadata())
.spec(comment.getSpec())
+ .status(comment.getStatus())
.build();
}
}
diff --git a/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java b/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java
index 6784d9c43..603c75834 100644
--- a/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java
+++ b/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java
@@ -1,9 +1,12 @@
package run.halo.app.theme.finders.vo;
+import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.Value;
+import lombok.With;
+import run.halo.app.content.comment.OwnerInfo;
import run.halo.app.core.extension.Reply;
import run.halo.app.extension.MetadataOperator;
@@ -19,10 +22,16 @@ import run.halo.app.extension.MetadataOperator;
@EqualsAndHashCode
public class ReplyVo {
+ @Schema(required = true)
MetadataOperator metadata;
+ @Schema(required = true)
Reply.ReplySpec spec;
+ @With
+ @Schema(required = true)
+ OwnerInfo owner;
+
/**
* Convert {@link Reply} to {@link ReplyVo}.
*
diff --git a/src/main/resources/extensions/role-template-anonymous.yaml b/src/main/resources/extensions/role-template-anonymous.yaml
index fd1570c40..e83eb249c 100644
--- a/src/main/resources/extensions/role-template-anonymous.yaml
+++ b/src/main/resources/extensions/role-template-anonymous.yaml
@@ -6,6 +6,6 @@ metadata:
halo.run/role-template: "true"
halo.run/hidden: "true"
rules:
- - apiGroups: [ "api.console.halo.run" ]
+ - apiGroups: [ "api.halo.run" ]
resources: [ "comments", "comments/reply" ]
- verbs: [ "create" ]
+ verbs: [ "create", "get", "list" ]
diff --git a/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java
index fe6a9bfed..e95d9c67a 100644
--- a/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java
+++ b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java
@@ -141,7 +141,7 @@ class CommentServiceImplTest {
"top": false,
"allowNotification": true,
"approved": false,
- "hidden": false,
+ "hidden": true,
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",
diff --git a/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java b/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java
new file mode 100644
index 000000000..c4cc602db
--- /dev/null
+++ b/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java
@@ -0,0 +1,127 @@
+package run.halo.app.theme.dialect;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationContext;
+import org.thymeleaf.IEngineConfiguration;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.context.Context;
+import org.thymeleaf.context.ITemplateContext;
+import org.thymeleaf.model.IProcessableElementTag;
+import org.thymeleaf.processor.element.IElementTagStructureHandler;
+import org.thymeleaf.spring6.dialect.SpringStandardDialect;
+import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
+import org.thymeleaf.templateresolver.StringTemplateResolver;
+import org.thymeleaf.templateresource.ITemplateResource;
+import org.thymeleaf.templateresource.StringTemplateResource;
+import run.halo.app.plugin.ExtensionComponentsFinder;
+
+/**
+ * Tests for {@link CommentElementTagProcessor}.
+ *
+ * @author guqing
+ * @see ExtensionComponentsFinder
+ * @see HaloProcessorDialect
+ * @since 2.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+class CommentElementTagProcessorTest {
+
+ @Mock
+ private ApplicationContext applicationContext;
+
+ @Mock
+ private ExtensionComponentsFinder componentsFinder;
+
+ private TemplateEngine templateEngine;
+
+ @BeforeEach
+ void setUp() {
+ HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect();
+ templateEngine = new TemplateEngine();
+ templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect()));
+ templateEngine.addTemplateResolver(new TestTemplateResolver());
+ when(applicationContext.getBean(eq(ExtensionComponentsFinder.class)))
+ .thenReturn(componentsFinder);
+ }
+
+ @Test
+ void doProcess() {
+ Context context = getContext();
+
+ String result = templateEngine.process("commentWidget", context);
+ assertThat(result).isEqualTo("""
+
+
+
+ comment widget:
+ \s
+
+
+ """);
+
+ when(componentsFinder.getExtensions(CommentWidget.class))
+ .thenReturn(List.of(new DefaultCommentWidget()));
+ result = templateEngine.process("commentWidget", context);
+ assertThat(result).isEqualTo("""
+
+
+
+ comment widget:
+ Comment in default widget
+
+
+ """);
+ }
+
+ static class DefaultCommentWidget implements CommentWidget {
+
+ @Override
+ public void render(ITemplateContext context, IProcessableElementTag tag,
+ IElementTagStructureHandler structureHandler) {
+ structureHandler.replaceWith("Comment in default widget
", false);
+ }
+ }
+
+ private Context getContext() {
+ Context context = new Context();
+ context.setVariable(
+ ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME,
+ new ThymeleafEvaluationContext(applicationContext, null));
+ return context;
+ }
+
+ static class TestTemplateResolver extends StringTemplateResolver {
+ @Override
+ protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
+ String ownerTemplate, String template,
+ Map templateResolutionAttributes) {
+ if (template.equals("commentWidget")) {
+ return new StringTemplateResource(commentWidget());
+ }
+ return null;
+ }
+
+ private String commentWidget() {
+ return """
+
+
+
+ comment widget:
+
+
+
+ """;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java b/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java
new file mode 100644
index 000000000..0802dc711
--- /dev/null
+++ b/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java
@@ -0,0 +1,175 @@
+package run.halo.app.theme.endpoint;
+
+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.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.comment.CommentRequest;
+import run.halo.app.content.comment.CommentService;
+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.Ref;
+import run.halo.app.theme.finders.CommentFinder;
+
+/**
+ * Tests for {@link CommentFinderEndpoint}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+class CommentFinderEndpointTest {
+ @Mock
+ private CommentFinder commentFinder;
+
+ @Mock
+ private CommentService commentService;
+
+ @Mock
+ private ReplyService replyService;
+
+ @InjectMocks
+ private CommentFinderEndpoint commentFinderEndpoint;
+
+ private WebTestClient webTestClient;
+
+ @BeforeEach
+ void setUp() {
+ webTestClient = WebTestClient
+ .bindToRouterFunction(commentFinderEndpoint.endpoint())
+ .build();
+ }
+
+ @Test
+ void listComments() {
+ when(commentFinder.list(any(), anyInt(), anyInt()))
+ .thenReturn(new ListResult<>(1, 10, 0, List.of()));
+
+ Ref ref = new Ref();
+ ref.setGroup("content.halo.run");
+ ref.setVersion("v1alpha1");
+ ref.setKind("Post");
+ ref.setName("test");
+ webTestClient.get()
+ .uri(uriBuilder -> {
+ return uriBuilder.path("/comments")
+ .queryParam("group", ref.getGroup())
+ .queryParam("version", ref.getVersion())
+ .queryParam("kind", ref.getKind())
+ .queryParam("name", ref.getName())
+ .queryParam("page", 1)
+ .queryParam("size", 10)
+ .build();
+ })
+ .exchange()
+ .expectStatus()
+ .isOk();
+ ArgumentCaptor[ refCaptor = ArgumentCaptor.forClass(Ref.class);
+ verify(commentFinder, times(1)).list(refCaptor.capture(), eq(1), eq(10));
+ Ref value = refCaptor.getValue();
+ assertThat(value).isEqualTo(ref);
+ }
+
+ @Test
+ void getComment() {
+ when(commentFinder.getByName(any()))
+ .thenReturn(null);
+
+ webTestClient.get()
+ .uri("/comments/test-comment")
+ .exchange()
+ .expectStatus()
+ .isOk();
+
+ verify(commentFinder, times(1)).getByName(eq("test-comment"));
+ }
+
+ @Test
+ void listCommentReplies() {
+ when(commentFinder.listReply(any(), anyInt(), anyInt()))
+ .thenReturn(new ListResult<>(2, 20, 0, List.of()));
+
+ webTestClient.get()
+ .uri(uriBuilder -> {
+ return uriBuilder.path("/comments/test-comment/reply")
+ .queryParam("page", 2)
+ .queryParam("size", 20)
+ .build();
+ })
+ .exchange()
+ .expectStatus()
+ .isOk();
+
+ verify(commentFinder, times(1)).listReply(eq("test-comment"), eq(2), eq(20));
+ }
+
+ @Test
+ void createComment() {
+ when(commentService.create(any())).thenReturn(Mono.empty());
+
+ final CommentRequest commentRequest = new CommentRequest();
+ Ref ref = new Ref();
+ ref.setGroup("content.halo.run");
+ ref.setVersion("v1alpha1");
+ ref.setKind("Post");
+ ref.setName("test-post");
+ commentRequest.setSubjectRef(ref);
+ commentRequest.setContent("content");
+ commentRequest.setRaw("raw");
+ commentRequest.setAllowNotification(false);
+ webTestClient.post()
+ .uri("/comments")
+ .bodyValue(commentRequest)
+ .exchange()
+ .expectStatus()
+ .isOk();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class);
+ verify(commentService, times(1)).create(captor.capture());
+ Comment value = captor.getValue();
+ assertThat(value.getSpec().getIpAddress()).isNotNull();
+ assertThat(value.getSpec().getUserAgent()).isNotNull();
+ assertThat(value.getSpec().getSubjectRef()).isEqualTo(ref);
+ }
+
+ @Test
+ void createReply() {
+ when(replyService.create(any(), any())).thenReturn(Mono.empty());
+
+ final ReplyRequest replyRequest = new ReplyRequest();
+ replyRequest.setRaw("raw");
+ replyRequest.setContent("content");
+ replyRequest.setAllowNotification(true);
+
+ webTestClient.post()
+ .uri("/comments/test-comment/reply")
+ .bodyValue(replyRequest)
+ .exchange()
+ .expectStatus()
+ .isOk();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Reply.class);
+ verify(replyService, times(1)).create(eq("test-comment"), captor.capture());
+ Reply value = captor.getValue();
+ assertThat(value.getSpec().getIpAddress()).isNotNull();
+ assertThat(value.getSpec().getUserAgent()).isNotNull();
+ assertThat(value.getSpec().getQuoteReply()).isNull();
+ }
+}
\ No newline at end of file
]