feat: add comment list APIs for theme-side (#2433)

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

#### What this PR does / why we need it:
- 新增主题端评论列表功能
- 新增 `<halo:comment />` html tag 用于扩展评论组件
- 插件可以实现 `CommentWidget` 扩展点来渲染评论组件 html 块,以替换 `<halo:comment />` 标签
#### Which issue(s) this PR fixes:

Fixes #2432

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

```release-note
新增主题端评论列表功能
```
pull/2477/head
guqing 2022-09-26 16:26:13 +08:00 committed by GitHub
parent 73c66289e0
commit ed8dddbafa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 759 additions and 20 deletions

View File

@ -43,7 +43,8 @@ public class SwaggerConfig {
.group("extension-api")
.displayName("Extension APIs")
.pathsToMatch("/apis/**")
.pathsToExclude("/apis/api.console.halo.run/**", "/apis/api.plugin.halo.run/**")
.pathsToExclude("/apis/api.console.halo.run/**", "/apis/api.halo.run/**",
"/apis/api.plugin.halo.run/**")
.build();
}
@ -56,6 +57,15 @@ public class SwaggerConfig {
.build();
}
@Bean
GroupedOpenApi customApi() {
return GroupedOpenApi.builder()
.group("api.halo.run")
.displayName("api.halo.run")
.pathsToMatch("/apis/api.halo.run/**")
.build();
}
@Bean
GroupedOpenApi pluginCustomApi() {
return GroupedOpenApi.builder()

View File

@ -68,6 +68,11 @@ public class CommentServiceImpl implements CommentService {
return Mono.error(
new AccessDeniedException("The comment function has been turned off."));
}
if (checkCommentOwner(comment, commentSetting.getSystemUserOnly())) {
return Mono.error(
new AccessDeniedException("Allow system user comments only."));
}
if (comment.getSpec().getTop() == null) {
comment.getSpec().setTop(false);
}
@ -76,7 +81,7 @@ public class CommentServiceImpl implements CommentService {
}
comment.getSpec()
.setApproved(Boolean.FALSE.equals(commentSetting.getRequireReviewForNew()));
comment.getSpec().setHidden(comment.getSpec().getApproved());
comment.getSpec().setHidden(!comment.getSpec().getApproved());
if (comment.getSpec().getOwner() != null) {
return Mono.just(comment);
}
@ -93,6 +98,14 @@ public class CommentServiceImpl implements CommentService {
.flatMap(client::create);
}
private boolean checkCommentOwner(Comment comment, Boolean onlySystemUser) {
Comment.CommentOwner owner = comment.getSpec().getOwner();
if (Boolean.TRUE.equals(onlySystemUser)) {
return owner != null && Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind());
}
return false;
}
private Comment.CommentOwner toCommentOwner(User user) {
Comment.CommentOwner owner = new Comment.CommentOwner();
owner.setKind(User.KIND);
@ -122,6 +135,7 @@ public class CommentServiceImpl implements CommentService {
builder.subject(subject);
return builder;
})
.switchIfEmpty(Mono.just(builder))
)
.map(ListedComment.ListedCommentBuilder::build);
}

View File

@ -56,9 +56,12 @@ public class ReplyServiceImpl implements ReplyService {
throw new AccessDeniedException(
"The comment function has been turned off.");
}
if (checkReplyOwner(reply, commentSetting.getSystemUserOnly())) {
throw new AccessDeniedException("Allow system user reply only.");
}
reply.getSpec().setApproved(
Boolean.FALSE.equals(commentSetting.getRequireReviewForNew()));
reply.getSpec().setHidden(reply.getSpec().getApproved());
reply.getSpec().setHidden(!reply.getSpec().getApproved());
return reply;
});
})
@ -82,6 +85,14 @@ public class ReplyServiceImpl implements ReplyService {
);
}
private boolean checkReplyOwner(Reply reply, Boolean onlySystemUser) {
Comment.CommentOwner owner = reply.getSpec().getOwner();
if (Boolean.TRUE.equals(onlySystemUser)) {
return owner != null && Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind());
}
return false;
}
@Override
public Mono<ListResult<ListedReply>> list(ReplyQuery query) {
return client.list(Reply.class, getReplyPredicate(query), defaultComparator(),

View File

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

View File

@ -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;
/**
* <p>Comment element tag processor.</p>
* <p>Replace the comment tag <code>&#x3C;halo:comment /&#x3E;</code> with the given content.</p>
*
* @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<CommentWidget> 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);
}
}

View File

@ -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 &#x3C;halo:comment /&#x3E; tag of the theme-side.
*
* @author guqing
* @since 2.0.0
*/
public interface CommentWidget extends ExtensionPoint {
void render(ITemplateContext context, IProcessableElementTag tag,
IElementTagStructureHandler structureHandler);
}

View File

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

View File

@ -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<ServerResponse> 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<ServerResponse> 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<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(CommentEndpoint.userAgentFrom(request));
return replyService.create(commentName, reply);
})
.flatMap(comment -> ServerResponse.ok().bodyValue(comment));
}
Mono<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<String, String> 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<String, String> queryParams) {
super(queryParams);
}
@Override
@JsonIgnore
public List<String> getLabelSelector() {
throw new UnsupportedOperationException("Unsupported this parameter");
}
@Override
@JsonIgnore
public List<String> getFieldSelector() {
throw new UnsupportedOperationException("Unsupported this parameter");
}
}
}

View File

@ -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<CommentVo> 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<ReplyVo> 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<CommentVo> toCommentVo(Comment comment) {
Comment.CommentOwner owner = comment.getSpec().getOwner();
return Mono.just(CommentVo.from(comment))
.flatMap(commentVo -> getOwnerInfo(owner)
.map(commentVo::withOwner)
);
}
private Mono<ReplyVo> toReplyVo(Reply reply) {
return Mono.just(ReplyVo.from(reply))
.flatMap(replyVo -> getOwnerInfo(reply.getSpec().getOwner())
.map(replyVo::withOwner)
);
}
private Mono<OwnerInfo> 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<Comment> fixedPredicate(Ref ref) {
Assert.notNull(ref, "Comment subject reference must not be null");
return comment -> comment.getSpec().getSubjectRef().equals(ref)

View File

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

View File

@ -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}.
*

View File

@ -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" ]

View File

@ -141,7 +141,7 @@ class CommentServiceImplTest {
"top": false,
"allowNotification": true,
"approved": false,
"hidden": false,
"hidden": true,
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",

View File

@ -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("""
<!DOCTYPE html>
<html lang="en">
<body>
<p>comment widget:</p>
\s
</body>
</html>
""");
when(componentsFinder.getExtensions(CommentWidget.class))
.thenReturn(List.of(new DefaultCommentWidget()));
result = templateEngine.process("commentWidget", context);
assertThat(result).isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<body>
<p>comment widget:</p>
<p>Comment in default widget</p>
</body>
</html>
""");
}
static class DefaultCommentWidget implements CommentWidget {
@Override
public void render(ITemplateContext context, IProcessableElementTag tag,
IElementTagStructureHandler structureHandler) {
structureHandler.replaceWith("<p>Comment in default widget</p>", 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<String, Object> templateResolutionAttributes) {
if (template.equals("commentWidget")) {
return new StringTemplateResource(commentWidget());
}
return null;
}
private String commentWidget() {
return """
<!DOCTYPE html>
<html lang="en">
<body>
<p>comment widget:</p>
<halo:comment/>
</body>
</html>
""";
}
}
}

View File

@ -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<Ref> 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<Comment> 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<Reply> 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();
}
}