From b9957542f4cbb5b2ab3b39bc3345ae758fe6fac8 Mon Sep 17 00:00:00 2001
From: guqing <38999863+guqing@users.noreply.github.com>
Date: Mon, 22 Aug 2022 15:32:11 +0800
Subject: [PATCH] feat: add post module basic implementation (#2326)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#### What type of PR is this?
/kind feature
/milestone 2.0
/area core
#### What this PR does / why we need it:
新增文章模块的基本实现
提供创建文章,更新草稿和发布文章三个 API
#### Which issue(s) this PR fixes:
A part of #2322
#### Special notes for your reviewer:
/cc @halo-dev/sig-halo
#### Does this PR introduce a user-facing change?
```release-note
None
```
---
build.gradle | 6 +
.../app/config/ExtensionConfiguration.java | 11 +
.../run/halo/app/content/ContentRequest.java | 52 +++
.../run/halo/app/content/ContentService.java | 30 ++
.../run/halo/app/content/ContentWrapper.java | 13 +
.../run/halo/app/content/Contributor.java | 16 +
.../java/run/halo/app/content/ListedPost.java | 31 ++
.../java/run/halo/app/content/PatchUtils.java | 88 +++++
.../java/run/halo/app/content/PostQuery.java | 25 ++
.../run/halo/app/content/PostRequest.java | 23 ++
.../run/halo/app/content/PostService.java | 22 ++
.../app/content/impl/ContentServiceImpl.java | 235 ++++++++++++
.../app/content/impl/PostServiceImpl.java | 237 ++++++++++++
.../run/halo/app/core/extension/Category.java | 60 +++
.../run/halo/app/core/extension/Comment.java | 88 +++++
.../run/halo/app/core/extension/Post.java | 152 ++++++++
.../run/halo/app/core/extension/Reply.java | 36 ++
.../run/halo/app/core/extension/Snapshot.java | 129 +++++++
.../java/run/halo/app/core/extension/Tag.java | 65 ++++
.../extension/endpoint/ContentEndpoint.java | 135 +++++++
.../core/extension/endpoint/PostEndpoint.java | 140 +++++++
.../extension/reconciler/PostReconciler.java | 151 ++++++++
.../run/halo/app/extension/ListResult.java | 19 +
.../java/run/halo/app/infra/Condition.java | 50 +++
.../run/halo/app/infra/ConditionStatus.java | 11 +
.../run/halo/app/infra/SchemeInitializer.java | 12 +
.../halo/app/content/ContentRequestTest.java | 135 +++++++
.../halo/app/content/ContentServiceTest.java | 363 ++++++++++++++++++
.../java/run/halo/app/content/TestPost.java | 92 +++++
.../app/content/impl/PostServiceImplTest.java | 77 ++++
.../extension/endpoint/PostEndpointTest.java | 86 +++++
.../reconciler/PostReconcilerTest.java | 81 ++++
.../halo/app/extension/ListResultTest.java | 8 +
33 files changed, 2679 insertions(+)
create mode 100644 src/main/java/run/halo/app/content/ContentRequest.java
create mode 100644 src/main/java/run/halo/app/content/ContentService.java
create mode 100644 src/main/java/run/halo/app/content/ContentWrapper.java
create mode 100644 src/main/java/run/halo/app/content/Contributor.java
create mode 100644 src/main/java/run/halo/app/content/ListedPost.java
create mode 100644 src/main/java/run/halo/app/content/PatchUtils.java
create mode 100644 src/main/java/run/halo/app/content/PostQuery.java
create mode 100644 src/main/java/run/halo/app/content/PostRequest.java
create mode 100644 src/main/java/run/halo/app/content/PostService.java
create mode 100644 src/main/java/run/halo/app/content/impl/ContentServiceImpl.java
create mode 100644 src/main/java/run/halo/app/content/impl/PostServiceImpl.java
create mode 100644 src/main/java/run/halo/app/core/extension/Category.java
create mode 100644 src/main/java/run/halo/app/core/extension/Comment.java
create mode 100644 src/main/java/run/halo/app/core/extension/Post.java
create mode 100644 src/main/java/run/halo/app/core/extension/Reply.java
create mode 100644 src/main/java/run/halo/app/core/extension/Snapshot.java
create mode 100644 src/main/java/run/halo/app/core/extension/Tag.java
create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/ContentEndpoint.java
create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java
create mode 100644 src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java
create mode 100644 src/main/java/run/halo/app/infra/Condition.java
create mode 100644 src/main/java/run/halo/app/infra/ConditionStatus.java
create mode 100644 src/test/java/run/halo/app/content/ContentRequestTest.java
create mode 100644 src/test/java/run/halo/app/content/ContentServiceTest.java
create mode 100644 src/test/java/run/halo/app/content/TestPost.java
create mode 100644 src/test/java/run/halo/app/content/impl/PostServiceImplTest.java
create mode 100644 src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java
create mode 100644 src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java
diff --git a/build.gradle b/build.gradle
index cd1cbbbee..d2b802949 100644
--- a/build.gradle
+++ b/build.gradle
@@ -43,6 +43,9 @@ ext {
commonsLang3 = "3.12.0"
base62 = "0.1.3"
pf4j = "3.6.0"
+ javaDiffUtils = "4.12"
+ guava = "31.1-jre"
+ jsoup = "1.15.2"
}
dependencies {
@@ -68,6 +71,9 @@ dependencies {
implementation "org.apache.commons:commons-lang3:$commonsLang3"
implementation "io.seruco.encoding:base62:$base62"
implementation "org.pf4j:pf4j:$pf4j"
+ implementation "com.google.guava:guava:$guava"
+ implementation "org.jsoup:jsoup:$jsoup"
+ implementation "io.github.java-diff-utils:java-diff-utils:$javaDiffUtils"
compileOnly 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java
index 96a8950d2..9fcf855e4 100644
--- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java
+++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java
@@ -5,9 +5,11 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
+import run.halo.app.content.ContentService;
import run.halo.app.core.extension.Menu;
import run.halo.app.core.extension.MenuItem;
import run.halo.app.core.extension.Plugin;
+import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.Theme;
@@ -15,6 +17,7 @@ import run.halo.app.core.extension.User;
import run.halo.app.core.extension.reconciler.MenuItemReconciler;
import run.halo.app.core.extension.reconciler.MenuReconciler;
import run.halo.app.core.extension.reconciler.PluginReconciler;
+import run.halo.app.core.extension.reconciler.PostReconciler;
import run.halo.app.core.extension.reconciler.RoleBindingReconciler;
import run.halo.app.core.extension.reconciler.RoleReconciler;
import run.halo.app.core.extension.reconciler.ThemeReconciler;
@@ -120,6 +123,14 @@ public class ExtensionConfiguration {
.extension(new Theme())
.build();
}
+
+ @Bean
+ Controller postController(ExtensionClient client, ContentService contentService) {
+ return new ControllerBuilder("post-controller", client)
+ .reconciler(new PostReconciler(client, contentService))
+ .extension(new Post())
+ .build();
+ }
}
}
diff --git a/src/main/java/run/halo/app/content/ContentRequest.java b/src/main/java/run/halo/app/content/ContentRequest.java
new file mode 100644
index 000000000..4dfee93e9
--- /dev/null
+++ b/src/main/java/run/halo/app/content/ContentRequest.java
@@ -0,0 +1,52 @@
+package run.halo.app.content;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import run.halo.app.core.extension.Snapshot;
+import run.halo.app.extension.Metadata;
+
+/**
+ * @author guqing
+ * @since 2.0.0
+ */
+public record ContentRequest(@Schema(required = true) Snapshot.SubjectRef subjectRef,
+ String headSnapshotName,
+ @Schema(required = true) String raw,
+ @Schema(required = true) String content,
+ @Schema(required = true) String rawType) {
+
+ public Snapshot toSnapshot() {
+ Snapshot snapshot = new Snapshot();
+
+ Metadata metadata = new Metadata();
+ metadata.setName(defaultName(subjectRef));
+ snapshot.setMetadata(metadata);
+
+ Snapshot.SnapShotSpec snapShotSpec = new Snapshot.SnapShotSpec();
+ snapShotSpec.setSubjectRef(subjectRef);
+ snapShotSpec.setVersion(1);
+ snapShotSpec.setRawType(rawType);
+ snapShotSpec.setRawPatch(raw);
+ snapShotSpec.setContentPatch(content);
+ String displayVersion = Snapshot.displayVersionFrom(snapShotSpec.getVersion());
+ snapShotSpec.setDisplayVersion(displayVersion);
+
+ snapshot.setSpec(snapShotSpec);
+ return snapshot;
+ }
+
+ private String defaultName(Snapshot.SubjectRef subjectRef) {
+ // example: Post-apost-v1-snapshot
+ return String.join("-", subjectRef.getKind(),
+ subjectRef.getName(), "v1", "snapshot");
+ }
+
+ public String rawPatchFrom(String originalRaw) {
+ // originalRaw content from v1
+ return PatchUtils.diffToJsonPatch(originalRaw, this.raw);
+ }
+
+ public String contentPatchFrom(String originalContent) {
+ // originalContent from v1
+ return PatchUtils.diffToJsonPatch(originalContent, this.content);
+ }
+}
diff --git a/src/main/java/run/halo/app/content/ContentService.java b/src/main/java/run/halo/app/content/ContentService.java
new file mode 100644
index 000000000..93187544c
--- /dev/null
+++ b/src/main/java/run/halo/app/content/ContentService.java
@@ -0,0 +1,30 @@
+package run.halo.app.content;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Snapshot;
+
+/**
+ * Content service for {@link Snapshot}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public interface ContentService {
+
+ Mono getContent(String name);
+
+ Mono draftContent(ContentRequest content);
+
+ Mono updateContent(ContentRequest content);
+
+ Mono publish(String headSnapshotName, Snapshot.SubjectRef subjectRef);
+
+ Mono getBaseSnapshot(Snapshot.SubjectRef subjectRef);
+
+ Mono latestSnapshotVersion(Snapshot.SubjectRef subjectRef);
+
+ Mono latestPublishedSnapshot(Snapshot.SubjectRef subjectRef);
+
+ Flux listSnapshots(Snapshot.SubjectRef subjectRef);
+}
diff --git a/src/main/java/run/halo/app/content/ContentWrapper.java b/src/main/java/run/halo/app/content/ContentWrapper.java
new file mode 100644
index 000000000..d264af887
--- /dev/null
+++ b/src/main/java/run/halo/app/content/ContentWrapper.java
@@ -0,0 +1,13 @@
+package run.halo.app.content;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * @author guqing
+ * @since 2.0.0
+ */
+public record ContentWrapper(@Schema(required = true) String snapshotName,
+ @Schema(required = true) String raw,
+ @Schema(required = true) String content,
+ @Schema(required = true) String rawType) {
+}
diff --git a/src/main/java/run/halo/app/content/Contributor.java b/src/main/java/run/halo/app/content/Contributor.java
new file mode 100644
index 000000000..fb86fb490
--- /dev/null
+++ b/src/main/java/run/halo/app/content/Contributor.java
@@ -0,0 +1,16 @@
+package run.halo.app.content;
+
+import lombok.Data;
+
+/**
+ * Contributor from user.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Data
+public class Contributor {
+ private String displayName;
+ private String avatar;
+ private String name;
+}
diff --git a/src/main/java/run/halo/app/content/ListedPost.java b/src/main/java/run/halo/app/content/ListedPost.java
new file mode 100644
index 000000000..7c1323369
--- /dev/null
+++ b/src/main/java/run/halo/app/content/ListedPost.java
@@ -0,0 +1,31 @@
+package run.halo.app.content;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import lombok.Data;
+import run.halo.app.core.extension.Category;
+import run.halo.app.core.extension.Post;
+import run.halo.app.core.extension.Tag;
+
+/**
+ * An aggregate object of {@link Post} and {@link Category}
+ * and {@link Tag} and more for post list.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Data
+public class ListedPost {
+
+ @Schema(required = true)
+ private Post post;
+
+ @Schema(required = true)
+ private List categories;
+
+ @Schema(required = true)
+ private List tags;
+
+ @Schema(required = true)
+ private List contributors;
+}
diff --git a/src/main/java/run/halo/app/content/PatchUtils.java b/src/main/java/run/halo/app/content/PatchUtils.java
new file mode 100644
index 000000000..1813c7516
--- /dev/null
+++ b/src/main/java/run/halo/app/content/PatchUtils.java
@@ -0,0 +1,88 @@
+package run.halo.app.content;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.github.difflib.DiffUtils;
+import com.github.difflib.patch.AbstractDelta;
+import com.github.difflib.patch.ChangeDelta;
+import com.github.difflib.patch.Chunk;
+import com.github.difflib.patch.DeleteDelta;
+import com.github.difflib.patch.DeltaType;
+import com.github.difflib.patch.InsertDelta;
+import com.github.difflib.patch.Patch;
+import com.github.difflib.patch.PatchFailedException;
+import com.google.common.base.Splitter;
+import java.util.Collections;
+import java.util.List;
+import lombok.Data;
+import org.apache.commons.lang3.StringUtils;
+import run.halo.app.infra.utils.JsonUtils;
+
+/**
+ * @author guqing
+ * @since 2.0.0
+ */
+public class PatchUtils {
+ private static final String DELIMITER = "\n";
+ private static final Splitter lineSplitter = Splitter.on(DELIMITER);
+
+ public static Patch create(String deltasJson) {
+ List deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() {
+ });
+ Patch patch = new Patch<>();
+ for (Delta delta : deltas) {
+ StringChunk sourceChunk = delta.getSource();
+ StringChunk targetChunk = delta.getTarget();
+ Chunk orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(),
+ sourceChunk.getChangePosition());
+ Chunk revChunk = new Chunk<>(targetChunk.getPosition(), targetChunk.getLines(),
+ targetChunk.getChangePosition());
+ switch (delta.getType()) {
+ case DELETE -> patch.addDelta(new DeleteDelta<>(orgChunk, revChunk));
+ case INSERT -> patch.addDelta(new InsertDelta<>(orgChunk, revChunk));
+ case CHANGE -> patch.addDelta(new ChangeDelta<>(orgChunk, revChunk));
+ default -> throw new IllegalArgumentException("Unsupported delta type.");
+ }
+ }
+ return patch;
+ }
+
+ public static String patchToJson(Patch patch) {
+ List> deltas = patch.getDeltas();
+ return JsonUtils.objectToJson(deltas);
+ }
+
+ public static String applyPatch(String original, String patchJson) {
+ Patch patch = PatchUtils.create(patchJson);
+ try {
+ return String.join(DELIMITER, patch.applyTo(breakLine(original)));
+ } catch (PatchFailedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static String diffToJsonPatch(String original, String revised) {
+ Patch patch = DiffUtils.diff(breakLine(original), breakLine(revised));
+ return PatchUtils.patchToJson(patch);
+ }
+
+ public static List breakLine(String content) {
+ if (StringUtils.isBlank(content)) {
+ return Collections.emptyList();
+ }
+ return lineSplitter.splitToList(content);
+ }
+
+ @Data
+ public static class Delta {
+ private StringChunk source;
+ private StringChunk target;
+ private DeltaType type;
+ }
+
+ @Data
+ public static class StringChunk {
+ private int position;
+ private List lines;
+ private List changePosition;
+ }
+}
diff --git a/src/main/java/run/halo/app/content/PostQuery.java b/src/main/java/run/halo/app/content/PostQuery.java
new file mode 100644
index 000000000..b44aec60a
--- /dev/null
+++ b/src/main/java/run/halo/app/content/PostQuery.java
@@ -0,0 +1,25 @@
+package run.halo.app.content;
+
+import java.util.Set;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import run.halo.app.core.extension.Post;
+import run.halo.app.extension.router.ListRequest;
+
+/**
+ * A query object for {@link Post} list.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class PostQuery extends ListRequest {
+
+ private Set contributors;
+
+ private Set categories;
+
+ private Set tags;
+ // TODO add more query fields
+}
diff --git a/src/main/java/run/halo/app/content/PostRequest.java b/src/main/java/run/halo/app/content/PostRequest.java
new file mode 100644
index 000000000..60fc63291
--- /dev/null
+++ b/src/main/java/run/halo/app/content/PostRequest.java
@@ -0,0 +1,23 @@
+package run.halo.app.content;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import run.halo.app.core.extension.Post;
+import run.halo.app.core.extension.Snapshot;
+
+/**
+ * @author guqing
+ * @since 2.0.0
+ */
+public record PostRequest(@Schema(required = true) Post post,
+ @Schema(required = true) Content content) {
+
+ public ContentRequest contentRequest() {
+ Snapshot.SubjectRef subjectRef =
+ Snapshot.SubjectRef.of(Post.KIND, post.getMetadata().getName());
+ return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw,
+ content.content, content.rawType);
+ }
+
+ public record Content(String raw, String content, String rawType) {
+ }
+}
diff --git a/src/main/java/run/halo/app/content/PostService.java b/src/main/java/run/halo/app/content/PostService.java
new file mode 100644
index 000000000..95a4b560b
--- /dev/null
+++ b/src/main/java/run/halo/app/content/PostService.java
@@ -0,0 +1,22 @@
+package run.halo.app.content;
+
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Post;
+import run.halo.app.extension.ListResult;
+
+/**
+ * Service for {@link Post}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public interface PostService {
+
+ Mono> listPost(PostQuery query);
+
+ Mono draftPost(PostRequest postRequest);
+
+ Mono updatePost(PostRequest postRequest);
+
+ Mono publishPost(String postName);
+}
diff --git a/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java b/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java
new file mode 100644
index 000000000..d59129ac6
--- /dev/null
+++ b/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java
@@ -0,0 +1,235 @@
+package run.halo.app.content.impl;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.UUID;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.ContentRequest;
+import run.halo.app.content.ContentService;
+import run.halo.app.content.ContentWrapper;
+import run.halo.app.core.extension.Snapshot;
+import run.halo.app.extension.ReactiveExtensionClient;
+
+/**
+ * A default implementation of {@link ContentService}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class ContentServiceImpl implements ContentService {
+ private static final Comparator SNAPSHOT_COMPARATOR =
+ Comparator.comparing(snapshot -> snapshot.getSpec().getVersion());
+ public static Comparator LATEST_SNAPSHOT_COMPARATOR = SNAPSHOT_COMPARATOR.reversed();
+
+ private final ReactiveExtensionClient client;
+
+ public ContentServiceImpl(ReactiveExtensionClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public Mono getContent(String name) {
+ return client.fetch(Snapshot.class, name)
+ .flatMap(snapshot -> getBaseSnapshot(snapshot.getSpec().getSubjectRef())
+ .map(snapshot::applyPatch));
+ }
+
+ @Override
+ public Mono draftContent(ContentRequest contentRequest) {
+ return getContextUsername()
+ .flatMap(username -> {
+ // create snapshot
+ Snapshot snapshot = contentRequest.toSnapshot();
+ snapshot.addContributor(username);
+ return client.create(snapshot)
+ .flatMap(this::restoredContent);
+ });
+ }
+
+ @Override
+ public Mono updateContent(ContentRequest contentRequest) {
+ Assert.notNull(contentRequest, "The contentRequest must not be null");
+ Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null");
+ return Mono.zip(getContextUsername(),
+ client.fetch(Snapshot.class, contentRequest.headSnapshotName()))
+ .flatMap(tuple -> {
+ String username = tuple.getT1();
+ Snapshot headSnapShot = tuple.getT2();
+ return handleSnapshot(headSnapShot, contentRequest, username);
+ })
+ .flatMap(snapshot -> restoredContent(snapshot)
+ .map(content -> new ContentWrapper(snapshot.getMetadata().getName(),
+ content.raw(), content.content(), content.rawType())
+ )
+ );
+ }
+
+ @Override
+ public Mono publish(String headSnapshotName, Snapshot.SubjectRef subjectRef) {
+ Assert.notNull(headSnapshotName, "The headSnapshotName must not be null");
+ return client.fetch(Snapshot.class, headSnapshotName)
+ .flatMap(snapshot -> {
+ if (snapshot.isPublished()) {
+ // there is nothing to publish
+ return restoredContent(snapshot.getMetadata().getName(),
+ subjectRef);
+ }
+
+ Snapshot.SnapShotSpec snapshotSpec = snapshot.getSpec();
+ snapshotSpec.setPublishTime(Instant.now());
+ snapshotSpec.setDisplayVersion(
+ Snapshot.displayVersionFrom(snapshotSpec.getVersion()));
+ return client.update(snapshot)
+ .then(Mono.defer(
+ () -> restoredContent(snapshot.getMetadata().getName(), subjectRef))
+ );
+ });
+ }
+
+ private Mono restoredContent(String snapshotName,
+ Snapshot.SubjectRef subjectRef) {
+ return getBaseSnapshot(subjectRef)
+ .flatMap(baseSnapshot -> client.fetch(Snapshot.class, snapshotName)
+ .map(snapshot -> snapshot.applyPatch(baseSnapshot)));
+ }
+
+ private Mono restoredContent(Snapshot headSnapshot) {
+ return getBaseSnapshot(headSnapshot.getSpec().getSubjectRef())
+ .map(headSnapshot::applyPatch);
+ }
+
+ @Override
+ public Mono getBaseSnapshot(Snapshot.SubjectRef subjectRef) {
+ return listSnapshots(subjectRef)
+ .filter(snapshot -> snapshot.getSpec().getVersion() == 1)
+ .next();
+ }
+
+ private Mono handleSnapshot(Snapshot headSnapshot, ContentRequest contentRequest,
+ String username) {
+ Snapshot.SubjectRef subjectRef = contentRequest.subjectRef();
+ return getBaseSnapshot(subjectRef).flatMap(baseSnapshot -> {
+ String baseSnapshotName = baseSnapshot.getMetadata().getName();
+ return latestPublishedSnapshot(subjectRef)
+ .flatMap(latestReleasedSnapshot -> {
+ Snapshot newSnapshot = contentRequest.toSnapshot();
+ newSnapshot.getSpec().setSubjectRef(subjectRef);
+ newSnapshot.addContributor(username);
+ // has released snapshot, there are 3 assumptions:
+ // if headPtr != releasePtr && head is not published, then update its content
+ // directly
+ // if headPtr != releasePtr && head is published, then create a new snapshot
+ // if headPtr == releasePtr, then create a new snapshot too
+ return latestSnapshotVersion(subjectRef)
+ .flatMap(latestSnapshot -> {
+ String headSnapshotName = contentRequest.headSnapshotName();
+ newSnapshot.getSpec()
+ .setVersion(latestSnapshot.getSpec().getVersion() + 1);
+ newSnapshot.getSpec().setDisplayVersion(
+ Snapshot.displayVersionFrom(newSnapshot.getSpec().getVersion()));
+ newSnapshot.getSpec()
+ .setParentSnapshotName(headSnapshotName);
+ // head is published or headPtr == releasePtr
+ String releasedSnapshotName =
+ latestReleasedSnapshot.getMetadata().getName();
+ if (headSnapshot.isPublished() || StringUtils.equals(headSnapshotName,
+ releasedSnapshotName)) {
+ // create a new snapshot,done
+ return createNewSnapshot(newSnapshot, baseSnapshotName,
+ contentRequest);
+ }
+
+ // otherwise update its content directly
+ return updateRawAndContentToHeadSnapshot(headSnapshot, baseSnapshotName,
+ contentRequest);
+ });
+ })
+ // no released snapshot, indicating v1 now, just update the content directly
+ .switchIfEmpty(Mono.defer(
+ () -> updateRawAndContentToHeadSnapshot(headSnapshot, baseSnapshotName,
+ contentRequest)));
+ });
+ }
+
+ @Override
+ public Mono latestSnapshotVersion(Snapshot.SubjectRef subjectRef) {
+ Assert.notNull(subjectRef, "The subjectRef must not be null.");
+ return listSnapshots(subjectRef)
+ .sort(LATEST_SNAPSHOT_COMPARATOR)
+ .next();
+ }
+
+ @Override
+ public Mono latestPublishedSnapshot(Snapshot.SubjectRef subjectRef) {
+ Assert.notNull(subjectRef, "The subjectRef must not be null.");
+ return listSnapshots(subjectRef)
+ .filter(Snapshot::isPublished)
+ .sort(LATEST_SNAPSHOT_COMPARATOR)
+ .next();
+ }
+
+ @Override
+ public Flux listSnapshots(Snapshot.SubjectRef subjectRef) {
+ Assert.notNull(subjectRef, "The subjectRef must not be null.");
+ return client.list(Snapshot.class, snapshot -> subjectRef.equals(snapshot.getSpec()
+ .getSubjectRef()), null);
+ }
+
+ private Mono getContextUsername() {
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .map(Principal::getName);
+ }
+
+ private Mono updateRawAndContentToHeadSnapshot(Snapshot snapshotToUpdate,
+ String baseSnapshotName,
+ ContentRequest contentRequest) {
+ return client.fetch(Snapshot.class, baseSnapshotName)
+ .flatMap(baseSnapshot -> {
+ determineRawAndContentPatch(snapshotToUpdate,
+ baseSnapshot, contentRequest);
+ return client.update(snapshotToUpdate)
+ .thenReturn(snapshotToUpdate);
+ });
+ }
+
+ private Mono createNewSnapshot(Snapshot snapshotToCreate,
+ String baseSnapshotName,
+ ContentRequest contentRequest) {
+ return client.fetch(Snapshot.class, baseSnapshotName)
+ .flatMap(baseSnapshot -> {
+ determineRawAndContentPatch(snapshotToCreate,
+ baseSnapshot, contentRequest);
+ snapshotToCreate.getMetadata().setName(UUID.randomUUID().toString());
+ snapshotToCreate.getSpec().setSubjectRef(contentRequest.subjectRef());
+ return client.create(snapshotToCreate)
+ .thenReturn(snapshotToCreate);
+ });
+ }
+
+ private void determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot,
+ ContentRequest contentRequest) {
+ String originalRaw = baseSnapshot.getSpec().getRawPatch();
+ String originalContent = baseSnapshot.getSpec().getContentPatch();
+
+ // it is the v1 snapshot, set the content directly
+ if (snapshotToUse.getSpec().getVersion() == 1) {
+ snapshotToUse.getSpec().setRawPatch(contentRequest.raw());
+ snapshotToUse.getSpec().setContentPatch(contentRequest.content());
+ } else {
+ // otherwise diff a patch based on the v1 snapshot
+ String revisedRaw = contentRequest.rawPatchFrom(originalRaw);
+ String revisedContent = contentRequest.contentPatchFrom(originalContent);
+ snapshotToUse.getSpec().setRawPatch(revisedRaw);
+ snapshotToUse.getSpec().setContentPatch(revisedContent);
+ }
+ }
+}
diff --git a/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java
new file mode 100644
index 000000000..5ecad6930
--- /dev/null
+++ b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java
@@ -0,0 +1,237 @@
+package run.halo.app.content.impl;
+
+import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.ContentService;
+import run.halo.app.content.Contributor;
+import run.halo.app.content.ListedPost;
+import run.halo.app.content.PostQuery;
+import run.halo.app.content.PostRequest;
+import run.halo.app.content.PostService;
+import run.halo.app.core.extension.Category;
+import run.halo.app.core.extension.Post;
+import run.halo.app.core.extension.Snapshot;
+import run.halo.app.core.extension.Tag;
+import run.halo.app.core.extension.User;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.infra.Condition;
+import run.halo.app.infra.ConditionStatus;
+
+/**
+ * A default implementation of {@link PostService}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class PostServiceImpl implements PostService {
+ private static final Comparator DEFAULT_POST_COMPARATOR =
+ Comparator.comparing(post -> post.getMetadata().getCreationTimestamp());
+ private final ContentService contentService;
+ private final ReactiveExtensionClient client;
+
+ public PostServiceImpl(ContentService contentService, ReactiveExtensionClient client) {
+ this.contentService = contentService;
+ this.client = client;
+ }
+
+ @Override
+ public Mono> listPost(PostQuery query) {
+ return client.list(Post.class, postListPredicate(query),
+ DEFAULT_POST_COMPARATOR.reversed(), query.getPage(), query.getSize())
+ .flatMap(listResult -> Flux.fromStream(
+ listResult.get().map(this::getListedPost)
+ )
+ .flatMap(Function.identity())
+ .collectList()
+ .map(listedPosts -> new ListResult<>(listResult.getPage(), listResult.getSize(),
+ listResult.getTotal(), listedPosts)
+ )
+ );
+ }
+
+ Predicate postListPredicate(PostQuery query) {
+ Predicate paramPredicate = post ->
+ contains(query.getCategories(), post.getSpec().getCategories())
+ && contains(query.getTags(), post.getSpec().getTags())
+ && contains(query.getContributors(), post.getStatus().getContributors());
+ Predicate predicate = labelAndFieldSelectorToPredicate(query.getLabelSelector(),
+ query.getFieldSelector());
+ return predicate.and(paramPredicate);
+ }
+
+ boolean contains(Collection left, List right) {
+ // parameter is null, it means that ignore this condition
+ if (left == null) {
+ return true;
+ }
+ // else, it means that right is empty
+ if (left.isEmpty()) {
+ return right.isEmpty();
+ }
+ if (right == null) {
+ return false;
+ }
+ return right.stream().anyMatch(left::contains);
+ }
+
+ private Mono getListedPost(Post post) {
+ Assert.notNull(post, "The post must not be null.");
+ ListedPost listedPost = new ListedPost();
+ listedPost.setPost(post);
+ return Mono.zip(listTags(post.getSpec().getTags()),
+ listCategories(post.getSpec().getCategories()),
+ listContributors(post.getStatusOrDefault().getContributors())
+ )
+ .map(tuple -> {
+ List tags = tuple.getT1();
+ List categories = tuple.getT2();
+ List contributors = tuple.getT3();
+ listedPost.setTags(tags);
+ listedPost.setCategories(categories);
+ listedPost.setContributors(contributors);
+ return listedPost;
+ });
+ }
+
+ private Mono> listTags(List tagNames) {
+ if (tagNames == null) {
+ return Mono.empty();
+ }
+ return Flux.fromStream(tagNames.stream()
+ .map(tagName -> client.fetch(Tag.class, tagName)))
+ .flatMap(Function.identity())
+ .collectList();
+ }
+
+ private Mono> listCategories(List categoryNames) {
+ if (categoryNames == null) {
+ return Mono.empty();
+ }
+ return Flux.fromStream(categoryNames.stream()
+ .map(categoryName -> client.fetch(Category.class, categoryName)))
+ .flatMap(Function.identity())
+ .collectList();
+ }
+
+ private Mono> listContributors(List usernames) {
+ if (usernames == null) {
+ return Mono.empty();
+ }
+ return Flux.fromIterable(usernames)
+ .map(username -> client.fetch(User.class, username)
+ .map(user -> {
+ Contributor contributor = new Contributor();
+ contributor.setName(username);
+ contributor.setDisplayName(user.getSpec().getDisplayName());
+ contributor.setAvatar(user.getSpec().getAvatar());
+ return contributor;
+ })
+ )
+ .flatMap(Function.identity())
+ .collectList();
+ }
+
+ @Override
+ public Mono draftPost(PostRequest postRequest) {
+ return contentService.draftContent(postRequest.contentRequest())
+ .flatMap(contentWrapper -> getContextUsername()
+ .flatMap(username -> {
+ Post post = postRequest.post();
+ post.getSpec().setBaseSnapshot(contentWrapper.snapshotName());
+ post.getSpec().setHeadSnapshot(contentWrapper.snapshotName());
+ post.getSpec().setOwner(username);
+ appendPublishedCondition(post, Post.PostPhase.DRAFT);
+ return client.create(post)
+ .then(Mono.defer(() ->
+ client.fetch(Post.class, postRequest.post().getMetadata().getName())));
+ }));
+ }
+
+ @Override
+ public Mono updatePost(PostRequest postRequest) {
+ Post post = postRequest.post();
+ return contentService.updateContent(postRequest.contentRequest())
+ .flatMap(contentWrapper -> {
+ post.getSpec().setHeadSnapshot(contentWrapper.snapshotName());
+ return client.update(post);
+ })
+ .then(Mono.defer(() -> client.fetch(Post.class, post.getMetadata().getName())));
+ }
+
+ private Mono getContextUsername() {
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .map(Principal::getName);
+ }
+
+ @Override
+ public Mono publishPost(String postName) {
+ return client.fetch(Post.class, postName)
+ .flatMap(post -> {
+ Post.PostSpec spec = post.getSpec();
+ // publish snapshot
+ return Mono.zip(Mono.just(post),
+ client.fetch(Snapshot.class, spec.getHeadSnapshot()));
+ })
+ .flatMap(tuple -> {
+ Post post = tuple.getT1();
+ Snapshot snapshot = tuple.getT2();
+
+ Post.PostSpec postSpec = post.getSpec();
+ if (Objects.equals(true, postSpec.getPublished())) {
+ // has been published before
+ postSpec.setVersion(postSpec.getVersion() + 1);
+ } else {
+ postSpec.setPublished(true);
+ }
+
+ if (postSpec.getPublishTime() == null) {
+ postSpec.setPublishTime(Instant.now());
+ }
+
+ // update release snapshot name and condition
+ postSpec.setReleaseSnapshot(snapshot.getMetadata().getName());
+ appendPublishedCondition(post, Post.PostPhase.PUBLISHED);
+
+ Snapshot.SubjectRef subjectRef =
+ Snapshot.SubjectRef.of(Post.KIND, post.getMetadata().getName());
+ return contentService.publish(snapshot.getMetadata().getName(), subjectRef)
+ .flatMap(contentWrapper -> {
+ post.getSpec().setReleaseSnapshot(contentWrapper.snapshotName());
+ return client.update(post);
+ })
+ .then(Mono.defer(() -> client.fetch(Post.class, postName)));
+ });
+ }
+
+ void appendPublishedCondition(Post post, Post.PostPhase phase) {
+ Assert.notNull(post, "The post must not be null.");
+ Post.PostStatus status = post.getStatusOrDefault();
+ status.setPhase(phase.name());
+ List conditions = status.getConditionsOrDefault();
+ Condition condition = new Condition();
+ conditions.add(condition);
+
+ condition.setType(phase.name());
+ condition.setReason(phase.name());
+ condition.setMessage("");
+ condition.setStatus(ConditionStatus.TRUE);
+ condition.setLastTransitionTime(Instant.now());
+ }
+}
diff --git a/src/main/java/run/halo/app/core/extension/Category.java b/src/main/java/run/halo/app/core/extension/Category.java
new file mode 100644
index 000000000..cfc3c410a
--- /dev/null
+++ b/src/main/java/run/halo/app/core/extension/Category.java
@@ -0,0 +1,60 @@
+package run.halo.app.core.extension;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import run.halo.app.extension.AbstractExtension;
+import run.halo.app.extension.GVK;
+
+/**
+ * @author guqing
+ * @see issue#2322
+ * @since 2.0.0
+ */
+@Data
+@ToString(callSuper = true)
+@GVK(group = "content.halo.run", version = "v1alpha1",
+ kind = "Category", plural = "categories", singular = "category")
+@EqualsAndHashCode(callSuper = true)
+public class Category extends AbstractExtension {
+
+ @Schema(required = true)
+ private CategorySpec spec;
+
+ @Schema
+ private CategoryStatus status;
+
+ @Data
+ public static class CategorySpec {
+
+ @Schema(required = true, minLength = 1)
+ private String displayName;
+
+ @Schema(required = true, minLength = 1)
+ private String slug;
+
+ private String description;
+
+ private String cover;
+
+ private String template;
+
+ @Schema(required = true, defaultValue = "0")
+ private Integer priority;
+
+ private List children;
+ }
+
+ @Data
+ public static class CategoryStatus {
+
+ private String permalink;
+
+ /**
+ * 包括当前和其下所有层级的文章 name (depth=max).
+ */
+ private List posts;
+ }
+}
diff --git a/src/main/java/run/halo/app/core/extension/Comment.java b/src/main/java/run/halo/app/core/extension/Comment.java
new file mode 100644
index 000000000..323513c50
--- /dev/null
+++ b/src/main/java/run/halo/app/core/extension/Comment.java
@@ -0,0 +1,88 @@
+package run.halo.app.core.extension;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.Map;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import run.halo.app.extension.AbstractExtension;
+import run.halo.app.extension.GVK;
+
+/**
+ * @author guqing
+ * @see issue#2322
+ * @since 2.0.0
+ */
+@Data
+@ToString(callSuper = true)
+@GVK(group = "content.halo.run", version = "v1alpha1",
+ kind = Comment.KIND, plural = "comments", singular = "comment")
+@EqualsAndHashCode(callSuper = true)
+public class Comment extends AbstractExtension {
+ public static final String KIND = "Comment";
+
+ @Schema(required = true)
+ private CommentSpec spec;
+
+ @Data
+ @EqualsAndHashCode(callSuper = true)
+ public static class CommentSpec extends BaseCommentSpec {
+
+ @Schema(required = true)
+ private CommentSubjectRef subjectRef;
+ }
+
+ @Data
+ public static class BaseCommentSpec {
+
+ @Schema(required = true, minLength = 1)
+ private String raw;
+
+ @Schema(required = true, minLength = 1)
+ private String content;
+
+ @Schema(required = true)
+ private CommentOwner owner;
+
+ private String userAgent;
+
+ private String ipAddress;
+
+ @Schema(required = true, defaultValue = "0")
+ private Integer priority;
+
+ @Schema(required = true, defaultValue = "false")
+ private Boolean top;
+
+ @Schema(required = true, defaultValue = "true")
+ private Boolean allowNotification;
+
+ @Schema(required = true, defaultValue = "false")
+ private Boolean approved;
+
+ @Schema(required = true, defaultValue = "false")
+ private Boolean hidden;
+ }
+
+ @Data
+ public static class CommentOwner {
+ @Schema(required = true, minLength = 1)
+ private String kind;
+
+ @Schema(required = true, minLength = 1, maxLength = 64)
+ private String name;
+
+ private String displayName;
+
+ private Map annotations;
+ }
+
+ @Data
+ public static class CommentSubjectRef {
+ @Schema(required = true, minLength = 1)
+ private String kind;
+
+ @Schema(required = true, minLength = 1, maxLength = 64)
+ private String name;
+ }
+}
diff --git a/src/main/java/run/halo/app/core/extension/Post.java b/src/main/java/run/halo/app/core/extension/Post.java
new file mode 100644
index 000000000..dc5e8ebcb
--- /dev/null
+++ b/src/main/java/run/halo/app/core/extension/Post.java
@@ -0,0 +1,152 @@
+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.ArrayList;
+import java.util.List;
+import java.util.Map;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import run.halo.app.extension.AbstractExtension;
+import run.halo.app.extension.GVK;
+import run.halo.app.infra.Condition;
+
+/**
+ * Post extension.
+ *
+ * @author guqing
+ * @see issue#2322
+ * @since 2.0.0
+ */
+@Data
+@ToString(callSuper = true)
+@GVK(group = "content.halo.run", version = "v1alpha1", kind = Post.KIND,
+ plural = "posts", singular = "post")
+@EqualsAndHashCode(callSuper = true)
+public class Post extends AbstractExtension {
+ public static final String KIND = "Post";
+ public static final String CATEGORIES_ANNO = "content.halo.run/categories";
+ public static final String TAGS_ANNO = "content.halo.run/tags";
+ public static final String DELETED_LABEL = "content.halo.run/deleted";
+ public static final String OWNER_LABEL = "content.halo.run/owner";
+ public static final String VISIBLE_LABEL = "content.halo.run/visible";
+ public static final String PHASE_LABEL = "content.halo.run/phase";
+
+ @Schema(required = true)
+ private PostSpec spec;
+
+ @Schema
+ private PostStatus status;
+
+ @JsonIgnore
+ public PostStatus getStatusOrDefault() {
+ if (this.status == null) {
+ this.status = new PostStatus();
+ }
+ return status;
+ }
+
+ @Data
+ public static class PostSpec {
+ @Schema(required = true, minLength = 1)
+ private String title;
+
+ @Schema(required = true, minLength = 1)
+ private String slug;
+
+ /**
+ * 文章引用到的已发布的内容,用于主题端显示.
+ */
+ private String releaseSnapshot;
+
+ private String headSnapshot;
+
+ private String baseSnapshot;
+
+ private String owner;
+
+ private String template;
+
+ private String cover;
+
+ @Schema(required = true, defaultValue = "false")
+ private Boolean deleted;
+
+ @Schema(required = true, defaultValue = "false")
+ private Boolean published;
+
+ private Instant publishTime;
+
+ @Schema(required = true, defaultValue = "false")
+ private Boolean pinned;
+
+ @Schema(required = true, defaultValue = "true")
+ private Boolean allowComment;
+
+ @Schema(required = true, defaultValue = "PUBLIC")
+ private VisibleEnum visible;
+
+ @Schema(required = true, defaultValue = "1")
+ private Integer version;
+
+ @Schema(required = true, defaultValue = "0")
+ private Integer priority;
+
+ @Schema(required = true)
+ private Excerpt excerpt;
+
+ private List categories;
+
+ private List tags;
+
+ private List
+ years ago our fathers
+
+ brought forth on this continent
+ """,
+ "MARKDOWN");
+ }
+
+ @Test
+ void toSnapshot() throws JSONException {
+ String expectedContentPath =
+ "Four score and seven
\nyears ago our fathers
\n
\nbrought forth "
+ + "on this continent
\n";
+ String expectedRawPatch =
+ "Four score and seven\nyears ago our fathers\n\nbrought forth on this continent\n";
+ Snapshot snapshot = contentRequest.toSnapshot();
+ snapshot.getMetadata().setName("7b149646-ac60-4a5c-98ee-78b2dd0631b2");
+ JSONAssert.assertEquals(JsonUtils.objectToJson(snapshot),
+ """
+ {
+ "spec": {
+ "subjectRef": {
+ "kind": "Post",
+ "name": "test-post"
+ },
+ "rawType": "MARKDOWN",
+ "rawPatch": "%s",
+ "contentPatch": "%s",
+ "parentSnapshotName": null,
+ "displayVersion": "v1",
+ "version": 1,
+ "publishTime": null,
+ "contributors": null
+ },
+ "apiVersion": "content.halo.run/v1alpha1",
+ "kind": "Snapshot",
+ "metadata": {
+ "name": "7b149646-ac60-4a5c-98ee-78b2dd0631b2",
+ "labels": null,
+ "annotations": null,
+ "version": null,
+ "creationTimestamp": null,
+ "deletionTimestamp": null
+ }
+ }
+ """.formatted(expectedRawPatch, expectedContentPath),
+ true);
+ }
+
+ @Test
+ void rawPatchFrom() throws JSONException {
+ String s = contentRequest.rawPatchFrom("""
+ Four score and seven
+ years ago our fathers
+ """);
+ JSONAssert.assertEquals(s,
+ """
+ [
+ {
+ "source": {
+ "position": 3,
+ "lines": [],
+ "changePosition": null
+ },
+ "target": {
+ "position": 3,
+ "lines": [
+ "brought forth on this continent",
+ ""
+ ],
+ "changePosition": null
+ },
+ "type": "INSERT"
+ }
+ ]
+ """, true);
+ }
+
+ @Test
+ void contentPatchFrom() throws JSONException {
+ String s = contentRequest.contentPatchFrom("""
+ Four score and seven
+ years ago our fathers
+ """);
+ JSONAssert.assertEquals(s, """
+ [
+ {
+ "source": {
+ "position": 2,
+ "lines": [],
+ "changePosition": null
+ },
+ "target": {
+ "position": 2,
+ "lines": [
+ "
",
+ "brought forth on this continent
"
+ ],
+ "changePosition": null
+ },
+ "type": "INSERT"
+ }
+ ]
+ """, true);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/content/ContentServiceTest.java b/src/test/java/run/halo/app/content/ContentServiceTest.java
new file mode 100644
index 000000000..a77c55638
--- /dev/null
+++ b/src/test/java/run/halo/app/content/ContentServiceTest.java
@@ -0,0 +1,363 @@
+package run.halo.app.content;
+
+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 static run.halo.app.content.TestPost.snapshotV1;
+import static run.halo.app.content.TestPost.snapshotV2;
+import static run.halo.app.content.TestPost.snapshotV3;
+
+import java.time.Instant;
+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.Mock;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+import run.halo.app.content.impl.ContentServiceImpl;
+import run.halo.app.core.extension.Post;
+import run.halo.app.core.extension.Snapshot;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.infra.utils.JsonUtils;
+
+/**
+ * Tests for {@link ContentService}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@WithMockUser(username = "guqing")
+@ExtendWith(SpringExtension.class)
+class ContentServiceTest {
+
+ @Mock
+ private ReactiveExtensionClient client;
+
+ private ContentService contentService;
+
+ @BeforeEach
+ void setUp() {
+ contentService = new ContentServiceImpl(client);
+ }
+
+ @Test
+ void draftContent() {
+ Snapshot snapshotV1 = snapshotV1();
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
+ snapshotV1.getSpec().setSubjectRef(subjectRef);
+
+ ContentRequest contentRequest =
+ new ContentRequest(subjectRef, null,
+ snapshotV1.getSpec().getRawPatch(),
+ snapshotV1.getSpec().getContentPatch(),
+ snapshotV1.getSpec().getRawType());
+
+ pilingBaseSnapshot(snapshotV1);
+
+ ContentWrapper contentWrapper =
+ new ContentWrapper("snapshot-A", contentRequest.raw(),
+ contentRequest.content(), snapshotV1.getSpec().getRawType());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Snapshot.class);
+ when(client.create(any())).thenReturn(Mono.just(snapshotV1));
+
+ StepVerifier.create(contentService.draftContent(contentRequest))
+ .expectNext(contentWrapper)
+ .expectComplete()
+ .verify();
+
+ verify(client, times(1)).create(captor.capture());
+ Snapshot snapshot = captor.getValue();
+
+ snapshotV1.getMetadata().setName(snapshot.getMetadata().getName());
+ snapshotV1.getSpec().setSubjectRef(subjectRef);
+ assertThat(snapshot).isEqualTo(snapshotV1);
+ }
+
+ @Test
+ void updateContent() {
+ String headSnapshot = "snapshot-A";
+ Snapshot snapshotV1 = snapshotV1();
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
+
+ Snapshot updated = snapshotV1();
+ updated.getSpec().setRawPatch("hello");
+ updated.getSpec().setContentPatch("hello
");
+ updated.getSpec().setSubjectRef(subjectRef);
+ ContentRequest contentRequest =
+ new ContentRequest(subjectRef, headSnapshot,
+ snapshotV1.getSpec().getRawPatch(),
+ snapshotV1.getSpec().getContentPatch(),
+ snapshotV1.getSpec().getRawType());
+
+ pilingBaseSnapshot(snapshotV1);
+
+ when(client.fetch(eq(Snapshot.class), eq(contentRequest.headSnapshotName())))
+ .thenReturn(Mono.just(updated));
+
+ ContentWrapper contentWrapper =
+ new ContentWrapper(headSnapshot, contentRequest.raw(),
+ contentRequest.content(), snapshotV1.getSpec().getRawType());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Snapshot.class);
+ when(client.update(any())).thenReturn(Mono.just(updated));
+
+ StepVerifier.create(contentService.updateContent(contentRequest))
+ .expectNext(contentWrapper)
+ .expectComplete()
+ .verify();
+
+ verify(client, times(1)).update(captor.capture());
+ Snapshot snapshot = captor.getValue();
+
+ assertThat(snapshot).isEqualTo(updated);
+ }
+
+ @Test
+ void updateContentWhenHasDraftVersionButHeadPoints2Published() {
+ final String headSnapshot = "snapshot-A";
+ Snapshot snapshotV1 = snapshotV1();
+
+ Snapshot snapshotV2 = snapshotV2();
+ snapshotV2.getSpec().setPublishTime(null);
+
+
+ // v1(released),v2
+ snapshotV1.getSpec().setPublishTime(Instant.now());
+ pilingBaseSnapshot(snapshotV2, snapshotV1);
+
+ when(client.fetch(eq(Snapshot.class), eq(snapshotV2.getMetadata().getName())))
+ .thenReturn(Mono.just(snapshotV2));
+ when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
+ .thenReturn(Mono.just(snapshotV1));
+
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
+
+ ContentRequest contentRequest =
+ new ContentRequest(subjectRef, headSnapshot, "C",
+ "C
", snapshotV1.getSpec().getRawType());
+
+ when(client.create(any())).thenReturn(Mono.just(snapshotV3()));
+
+ StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
+ .expectNext(snapshotV2)
+ .expectComplete()
+ .verify();
+
+ StepVerifier.create(contentService.updateContent(contentRequest))
+ .consumeNextWith(created -> {
+ assertThat(created.raw()).isEqualTo("C");
+ assertThat(created.content()).isEqualTo("C
");
+ })
+ .expectComplete()
+ .verify();
+ }
+
+ @Test
+ void updateContentWhenHeadPoints2Published() {
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
+
+ // v1(released),v2
+ Snapshot snapshotV1 = snapshotV1();
+ snapshotV1.getSpec().setPublishTime(Instant.now());
+ snapshotV1.getSpec().setSubjectRef(subjectRef);
+
+ Snapshot snapshotV2 = snapshotV2();
+ snapshotV2.getSpec().setSubjectRef(subjectRef);
+ snapshotV2.getSpec().setPublishTime(null);
+
+ final String headSnapshot = snapshotV2.getMetadata().getName();
+
+
+ pilingBaseSnapshot(snapshotV2, snapshotV1);
+
+ when(client.fetch(eq(Snapshot.class), eq(snapshotV2.getMetadata().getName())))
+ .thenReturn(Mono.just(snapshotV2));
+ when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
+ .thenReturn(Mono.just(snapshotV1));
+
+ ContentRequest contentRequest =
+ new ContentRequest(subjectRef, headSnapshot, "C",
+ "C
", snapshotV1.getSpec().getRawType());
+
+ when(client.update(any())).thenReturn(Mono.just(snapshotV2()));
+
+ StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
+ .expectNext(snapshotV2)
+ .expectComplete()
+ .verify();
+
+ StepVerifier.create(contentService.updateContent(contentRequest))
+ .consumeNextWith(updated -> {
+ assertThat(updated.raw()).isEqualTo("C");
+ assertThat(updated.content()).isEqualTo("C
");
+ })
+ .expectComplete()
+ .verify();
+
+ verify(client, times(1)).update(any());
+ }
+
+ @Test
+ void publishContent() {
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
+
+ // v1(released),v2
+ Snapshot snapshotV1 = snapshotV1();
+ snapshotV1.getSpec().setPublishTime(null);
+ snapshotV1.getSpec().setSubjectRef(subjectRef);
+
+ final String headSnapshot = snapshotV1.getMetadata().getName();
+
+ pilingBaseSnapshot(snapshotV1);
+
+ when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
+ .thenReturn(Mono.just(snapshotV1));
+
+ when(client.update(any())).thenReturn(Mono.just(snapshotV2()));
+
+ StepVerifier.create(contentService.publish(headSnapshot, subjectRef))
+ .expectNext()
+ .consumeNextWith(p -> {
+ System.out.println(JsonUtils.objectToJson(p));
+ })
+ .expectComplete()
+ .verify();
+ // has benn published,do nothing
+ verify(client, times(1)).update(any());
+ }
+
+ @Test
+ void publishContentWhenHasPublishedThenDoNothing() {
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
+
+ // v1(released),v2
+ Snapshot snapshotV1 = snapshotV1();
+ snapshotV1.getSpec().setPublishTime(Instant.now());
+ snapshotV1.getSpec().setSubjectRef(subjectRef);
+
+ final String headSnapshot = snapshotV1.getMetadata().getName();
+
+ pilingBaseSnapshot(snapshotV1);
+
+ when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
+ .thenReturn(Mono.just(snapshotV1));
+
+ when(client.update(any())).thenReturn(Mono.just(snapshotV2()));
+
+ StepVerifier.create(contentService.publish(headSnapshot, subjectRef))
+ .expectNext()
+ .consumeNextWith(p -> {
+ System.out.println(JsonUtils.objectToJson(p));
+ })
+ .expectComplete()
+ .verify();
+ // has benn published,do nothing
+ verify(client, times(0)).update(any());
+ }
+
+ private void pilingBaseSnapshot(Snapshot... expected) {
+ when(client.list(eq(Snapshot.class), any(), any()))
+ .thenReturn(Flux.just(expected));
+ }
+
+ @Test
+ void baseSnapshotVersion() {
+ String postName = "post-1";
+ Snapshot snapshotV1 = snapshotV1();
+ snapshotV1.getSpec().setPublishTime(Instant.now());
+ snapshotV1.setSubjectRef(Post.KIND, postName);
+
+ Snapshot snapshotV2 = TestPost.snapshotV2();
+ snapshotV2.setSubjectRef(Post.KIND, postName);
+
+ Snapshot snapshotV3 = TestPost.snapshotV3();
+ snapshotV3.setSubjectRef(Post.KIND, postName);
+
+ when(client.list(eq(Snapshot.class), any(), any()))
+ .thenReturn(Flux.just(snapshotV2, snapshotV1, snapshotV3));
+
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
+ StepVerifier.create(contentService.getBaseSnapshot(subjectRef))
+ .expectNext(snapshotV1)
+ .expectComplete()
+ .verify();
+ }
+
+ @Test
+ void latestSnapshotVersion() {
+ String postName = "post-1";
+ Snapshot snapshotV1 = snapshotV1();
+ snapshotV1.getSpec().setPublishTime(Instant.now());
+ snapshotV1.setSubjectRef(Post.KIND, postName);
+ Snapshot snapshotV2 = TestPost.snapshotV2();
+ snapshotV2.setSubjectRef(Post.KIND, postName);
+
+ when(client.list(eq(Snapshot.class), any(), any()))
+ .thenReturn(Flux.just(snapshotV1, snapshotV2));
+
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
+ StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
+ .expectNext(snapshotV2)
+ .expectComplete()
+ .verify();
+
+ when(client.list(eq(Snapshot.class), any(), any()))
+ .thenReturn(Flux.just(snapshotV1, snapshotV2, snapshotV3()));
+ StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
+ .expectNext(snapshotV3())
+ .expectComplete()
+ .verify();
+ }
+
+ @Test
+ void latestPublishedSnapshotThenV1() {
+ String postName = "post-1";
+ Snapshot snapshotV1 = snapshotV1();
+ snapshotV1.setSubjectRef(Post.KIND, postName);
+ snapshotV1.getSpec().setPublishTime(Instant.now());
+
+ Snapshot snapshotV2 = TestPost.snapshotV2();
+ snapshotV2.setSubjectRef(Post.KIND, postName);
+ snapshotV2.getSpec().setPublishTime(null);
+
+ when(client.list(eq(Snapshot.class), any(), any()))
+ .thenReturn(Flux.just(snapshotV1, snapshotV2));
+
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
+ StepVerifier.create(contentService.latestPublishedSnapshot(subjectRef))
+ .expectNext(snapshotV1)
+ .expectComplete()
+ .verify();
+ }
+
+ @Test
+ void latestPublishedSnapshotThenV2() {
+ String postName = "post-1";
+ Snapshot snapshotV1 = snapshotV1();
+ snapshotV1.setSubjectRef(Post.KIND, postName);
+ snapshotV1.getSpec().setPublishTime(Instant.now());
+
+ Snapshot snapshotV2 = TestPost.snapshotV2();
+ snapshotV2.setSubjectRef(Post.KIND, postName);
+ snapshotV2.getSpec().setPublishTime(Instant.now());
+
+ when(client.list(eq(Snapshot.class), any(), any()))
+ .thenReturn(Flux.just(snapshotV2, snapshotV1));
+
+ Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
+
+ StepVerifier.create(contentService.latestPublishedSnapshot(subjectRef))
+ .expectNext(snapshotV2)
+ .expectComplete()
+ .verify();
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/content/TestPost.java b/src/test/java/run/halo/app/content/TestPost.java
new file mode 100644
index 000000000..f9082ed31
--- /dev/null
+++ b/src/test/java/run/halo/app/content/TestPost.java
@@ -0,0 +1,92 @@
+package run.halo.app.content;
+
+import run.halo.app.core.extension.Post;
+import run.halo.app.core.extension.Snapshot;
+import run.halo.app.extension.AbstractExtension;
+import run.halo.app.extension.GVK;
+import run.halo.app.extension.Metadata;
+
+/**
+ * @author guqing
+ * @since 2.0.0
+ */
+public class TestPost {
+ public static Post postV1() {
+ Post post = new Post();
+ post.setKind(Post.KIND);
+ post.setApiVersion(getApiVersion(Post.class));
+ Metadata metadata = new Metadata();
+ metadata.setName("post-A");
+ post.setMetadata(metadata);
+
+ Post.PostSpec postSpec = new Post.PostSpec();
+ post.setSpec(postSpec);
+
+ postSpec.setTitle("post-A");
+ postSpec.setVersion(1);
+ postSpec.setBaseSnapshot(snapshotV1().getMetadata().getName());
+ postSpec.setHeadSnapshot("base-snapshot");
+ postSpec.setReleaseSnapshot(null);
+
+ return post;
+ }
+
+ public static Snapshot snapshotV1() {
+ Snapshot snapshot = new Snapshot();
+ snapshot.setKind(Snapshot.KIND);
+ snapshot.setApiVersion(getApiVersion(Snapshot.class));
+ Metadata metadata = new Metadata();
+ metadata.setName("snapshot-A");
+ snapshot.setMetadata(metadata);
+ Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec();
+ snapshot.setSpec(spec);
+
+ spec.setDisplayVersion("v1");
+ spec.setVersion(1);
+ snapshot.addContributor("guqing");
+ spec.setRawType("MARKDOWN");
+ spec.setRawPatch("A");
+ spec.setContentPatch("A
");
+
+ return snapshot;
+ }
+
+ public static Snapshot snapshotV2() {
+ Snapshot snapshot = new Snapshot();
+ snapshot.setKind(Snapshot.KIND);
+ snapshot.setApiVersion(getApiVersion(Snapshot.class));
+ Metadata metadata = new Metadata();
+ metadata.setName("snapshot-B");
+ snapshot.setMetadata(metadata);
+ Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec();
+ snapshot.setSpec(spec);
+
+ spec.setDisplayVersion("v2");
+ spec.setVersion(2);
+ snapshot.addContributor("guqing");
+ spec.setRawType("MARKDOWN");
+ spec.setRawPatch(PatchUtils.diffToJsonPatch("A", "B"));
+ spec.setContentPatch(PatchUtils.diffToJsonPatch("A
", "B
"));
+
+ return snapshot;
+ }
+
+ public static Snapshot snapshotV3() {
+ Snapshot snapshotV3 = snapshotV2();
+ snapshotV3.getMetadata().setName("snapshot-C");
+ Snapshot.SnapShotSpec spec = snapshotV3.getSpec();
+ spec.setDisplayVersion("v3");
+ spec.setVersion(3);
+ snapshotV3.addContributor("guqing");
+ spec.setRawType("MARKDOWN");
+ spec.setRawPatch(PatchUtils.diffToJsonPatch("B", "C"));
+ spec.setContentPatch(PatchUtils.diffToJsonPatch("B
", "C
"));
+
+ return snapshotV3;
+ }
+
+ public static String getApiVersion(Class extends AbstractExtension> extension) {
+ GVK annotation = extension.getAnnotation(GVK.class);
+ return annotation.group() + "/" + annotation.version();
+ }
+}
diff --git a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java
new file mode 100644
index 000000000..d906f444e
--- /dev/null
+++ b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java
@@ -0,0 +1,77 @@
+package run.halo.app.content.impl;
+
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+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 run.halo.app.content.ContentService;
+import run.halo.app.content.PostQuery;
+import run.halo.app.content.TestPost;
+import run.halo.app.core.extension.Post;
+import run.halo.app.extension.ReactiveExtensionClient;
+
+/**
+ * Tests for {@link PostServiceImpl}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+class PostServiceImplTest {
+ @Mock
+ private ReactiveExtensionClient client;
+
+ @Mock
+ private ContentService contentService;
+
+ private PostServiceImpl postService;
+
+ @BeforeEach
+ void setUp() {
+ postService = new PostServiceImpl(contentService, client);
+ }
+
+ @Test
+ void listPredicate() {
+ PostQuery postQuery = new PostQuery();
+ postQuery.setCategories(Set.of("category1", "category2"));
+
+ Post post = TestPost.postV1();
+ post.getSpec().setTags(null);
+ post.getStatusOrDefault().setContributors(null);
+ post.getSpec().setCategories(List.of("category1"));
+ boolean test = postService.postListPredicate(postQuery).test(post);
+ assertThat(test).isTrue();
+
+ post.getSpec().setTags(List.of("tag1"));
+ test = postService.postListPredicate(postQuery).test(post);
+ assertThat(test).isTrue();
+
+ // Do not include tags
+ postQuery.setTags(Set.of("tag2"));
+ post.getSpec().setTags(List.of("tag1"));
+ test = postService.postListPredicate(postQuery).test(post);
+ assertThat(test).isFalse();
+
+ postQuery.setTags(Set.of());
+ post.getSpec().setTags(List.of());
+ test = postService.postListPredicate(postQuery).test(post);
+ assertThat(test).isTrue();
+
+ postQuery.setLabelSelector(List.of("hello"));
+ test = postService.postListPredicate(postQuery).test(post);
+ assertThat(test).isFalse();
+
+ postQuery.setLabelSelector(List.of("hello"));
+ post.getMetadata().setLabels(Map.of("hello", "world"));
+ test = postService.postListPredicate(postQuery).test(post);
+ assertThat(test).isTrue();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java
new file mode 100644
index 000000000..e99e162b6
--- /dev/null
+++ b/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java
@@ -0,0 +1,86 @@
+package run.halo.app.core.extension.endpoint;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+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.test.web.reactive.server.WebTestClient;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.PostRequest;
+import run.halo.app.content.PostService;
+import run.halo.app.content.TestPost;
+import run.halo.app.core.extension.Post;
+
+/**
+ * Tests for @{@link PostEndpoint}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+class PostEndpointTest {
+ @Mock
+ private PostService postService;
+
+ private WebTestClient webTestClient;
+
+ @BeforeEach
+ void setUp() {
+ PostEndpoint postEndpoint = new PostEndpoint(postService);
+
+ webTestClient = WebTestClient
+ .bindToRouterFunction(postEndpoint.endpoint())
+ .build();
+ }
+
+ @Test
+ void draftPost() {
+ when(postService.draftPost(any())).thenReturn(Mono.just(TestPost.postV1()));
+
+ webTestClient.post()
+ .uri("/posts")
+ .bodyValue(postRequest(TestPost.postV1()))
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(Post.class)
+ .value(post -> assertThat(post).isEqualTo(TestPost.postV1()));
+ }
+
+ @Test
+ void updatePost() {
+ when(postService.updatePost(any())).thenReturn(Mono.just(TestPost.postV1()));
+
+ webTestClient.put()
+ .uri("/posts/post-A")
+ .bodyValue(postRequest(TestPost.postV1()))
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(Post.class)
+ .value(post -> assertThat(post).isEqualTo(TestPost.postV1()));
+ }
+
+ @Test
+ void publishPost() {
+ when(postService.publishPost(any())).thenReturn(Mono.just(TestPost.postV1()));
+
+ webTestClient.put()
+ .uri("/posts/post-A/publish")
+ .bodyValue(postRequest(TestPost.postV1()))
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(Post.class)
+ .value(post -> assertThat(post).isEqualTo(TestPost.postV1()));
+ }
+
+ PostRequest postRequest(Post post) {
+ return new PostRequest(post, new PostRequest.Content("B", "B
", "MARKDOWN"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java
new file mode 100644
index 000000000..902b965c3
--- /dev/null
+++ b/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java
@@ -0,0 +1,81 @@
+package run.halo.app.core.extension.reconciler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.ContentService;
+import run.halo.app.content.ContentWrapper;
+import run.halo.app.content.TestPost;
+import run.halo.app.core.extension.Post;
+import run.halo.app.core.extension.Snapshot;
+import run.halo.app.extension.ExtensionClient;
+import run.halo.app.extension.controller.Reconciler;
+
+/**
+ * Tests for {@link PostReconciler}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+class PostReconcilerTest {
+
+ @Mock
+ private ExtensionClient client;
+ @Mock
+ private ContentService contentService;
+
+ private PostReconciler postReconciler;
+
+ @BeforeEach
+ void setUp() {
+ postReconciler = new PostReconciler(client, contentService);
+ }
+
+ @Test
+ void reconcile() {
+ String name = "post-A";
+ Post post = TestPost.postV1();
+ post.getSpec().setHeadSnapshot("post-A-head-snapshot");
+ when(client.fetch(eq(Post.class), eq(name)))
+ .thenReturn(Optional.of(post));
+ when(contentService.getContent(eq(post.getSpec().getHeadSnapshot())))
+ .thenReturn(Mono.just(
+ new ContentWrapper(post.getSpec().getHeadSnapshot(), "hello world",
+ "hello world
", "markdown")));
+
+ Snapshot snapshotV1 = TestPost.snapshotV1();
+ Snapshot snapshotV2 = TestPost.snapshotV2();
+ snapshotV1.getSpec().setContributors(Set.of("guqing"));
+ snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan"));
+ when(contentService.listSnapshots(any()))
+ .thenReturn(Flux.just(snapshotV1, snapshotV2));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class);
+ postReconciler.reconcile(new Reconciler.Request(name));
+
+ verify(client, times(1)).update(captor.capture());
+
+ Post value = captor.getValue();
+ assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world");
+ assertThat(value.getStatus().getPermalink()).isEqualTo(
+ PostReconciler.PERMALINK_PREFIX + name);
+ assertThat(value.getStatus().getContributors()).isEqualTo(List.of("guqing", "zhangsan"));
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/extension/ListResultTest.java b/src/test/java/run/halo/app/extension/ListResultTest.java
index c47db21b9..b64f59348 100644
--- a/src/test/java/run/halo/app/extension/ListResultTest.java
+++ b/src/test/java/run/halo/app/extension/ListResultTest.java
@@ -19,4 +19,12 @@ class ListResultTest {
assertEquals("FakeList", fakeListClass.getSimpleName());
}
+ @Test
+ void generateGenericClassForClassParam() {
+ var fakeListClass = ListResult.generateGenericClass(FakeExtension.class);
+ assertTrue(ListResult.class.isAssignableFrom(fakeListClass));
+ assertSame(FakeExtension.class, ((ParameterizedType) fakeListClass.getGenericSuperclass())
+ .getActualTypeArguments()[0]);
+ assertEquals("FakeExtensionList", fakeListClass.getSimpleName());
+ }
}
\ No newline at end of file