diff --git a/detekt.yml b/detekt.yml index 73a9d00a..d0f97bdc 100644 --- a/detekt.yml +++ b/detekt.yml @@ -1,5 +1,7 @@ build: maxIssues: 20 + weights: + Indentation: 0 style: ClassOrdering: @@ -32,6 +34,15 @@ style: ForbiddenComment: active: false + ThrowsCount: + active: false + + UseCheckOrError: + active: false + + UseRequire: + active: false + complexity: CognitiveComplexMethod: active: true diff --git a/src/main/kotlin/dev/usbharu/hideout/Application.kt b/src/main/kotlin/dev/usbharu/hideout/Application.kt index 6a6dbf15..6b0b8c90 100644 --- a/src/main/kotlin/dev/usbharu/hideout/Application.kt +++ b/src/main/kotlin/dev/usbharu/hideout/Application.kt @@ -100,11 +100,12 @@ fun Application.parent() { inject().value, ) configureRouting( - inject().value, - inject().value, - inject().value, - inject().value, - inject().value + httpSignatureVerifyService = inject().value, + activityPubService = inject().value, + userService = inject().value, + activityPubUserService = inject().value, + postService = inject().value, + userApiService = inject().value, ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/Acct.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/Acct.kt new file mode 100644 index 00000000..af771e1b --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/Acct.kt @@ -0,0 +1,3 @@ +package dev.usbharu.hideout.domain.model + +data class Acct(val username: String, val domain: String? = null, val isRemote: Boolean = domain == null) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/Posts.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/Posts.kt deleted file mode 100644 index 56ae54a3..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/domain/model/Posts.kt +++ /dev/null @@ -1,54 +0,0 @@ -package dev.usbharu.hideout.domain.model - -import dev.usbharu.hideout.repository.Users -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.Table - -object Posts : Table() { - val id = long("id") - val userId = long("userId").references(Users.id) - val overview = varchar("overview", 100).nullable() - val text = varchar("text", 3000) - val createdAt = long("createdAt") - val visibility = integer("visibility").default(0) - val url = varchar("url", 500) - val repostId = long("repostId").references(id).nullable() - val replyId = long("replyId").references(id).nullable() - override val primaryKey: PrimaryKey = PrimaryKey(id) -} - -data class Post( - val userId: Long, - val overview: String? = null, - val text: String, - val createdAt: Long, - val visibility: Int, - val repostId: Long? = null, - val replyId: Long? = null -) - -data class PostEntity( - val id: Long, - val userId: Long, - val overview: String? = null, - val text: String, - val createdAt: Long, - val visibility: Int, - val url: String, - val repostId: Long? = null, - val replyId: Long? = null -) - -fun ResultRow.toPost(): PostEntity { - return PostEntity( - id = this[Posts.id], - userId = this[Posts.userId], - overview = this[Posts.overview], - text = this[Posts.text], - createdAt = this[Posts.createdAt], - visibility = this[Posts.visibility], - url = this[Posts.url], - repostId = this[Posts.repostId], - replyId = this[Posts.replyId] - ) -} diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Follow.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Follow.kt index 7977721d..8e29fbb5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Follow.kt +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Follow.kt @@ -9,7 +9,11 @@ open class Follow : Object { name: String, `object`: String?, actor: String? - ) : super(add(type, "Follow"), name, actor) { + ) : super( + type = add(type, "Follow"), + name = name, + actor = actor + ) { this.`object` = `object` } } diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Key.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Key.kt index 850bacb8..b5fd3529 100644 --- a/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Key.kt +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Key.kt @@ -8,10 +8,14 @@ open class Key : Object { constructor( type: List, name: String, - id: String?, + id: String, owner: String?, publicKeyPem: String? - ) : super(add(type, "Key"), name, id) { + ) : super( + type = add(list = type, type = "Key"), + name = name, + id = id + ) { this.owner = owner this.publicKeyPem = publicKeyPem } @@ -21,16 +25,16 @@ open class Key : Object { if (other !is Key) return false if (!super.equals(other)) return false - if (id != other.id) return false if (owner != other.owner) return false return publicKeyPem == other.publicKeyPem } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (id?.hashCode() ?: 0) result = 31 * result + (owner?.hashCode() ?: 0) result = 31 * result + (publicKeyPem?.hashCode() ?: 0) return result } + + override fun toString(): String = "Key(owner=$owner, publicKeyPem=$publicKeyPem) ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Object.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Object.kt index ae52c978..5d673244 100644 --- a/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Object.kt +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/ap/Object.kt @@ -27,7 +27,8 @@ open class Object : JsonLd { if (type != other.type) return false if (name != other.name) return false - return actor == other.actor + if (actor != other.actor) return false + return id == other.id } override fun hashCode(): Int { @@ -35,10 +36,11 @@ open class Object : JsonLd { result = 31 * result + type.hashCode() result = 31 * result + (name?.hashCode() ?: 0) result = 31 * result + (actor?.hashCode() ?: 0) + result = 31 * result + (id?.hashCode() ?: 0) return result } - override fun toString(): String = "Object(type=$type, name=$name, actor=$actor) ${super.toString()}" + override fun toString(): String = "Object(type=$type, name=$name, actor=$actor, id=$id) ${super.toString()}" companion object { @JvmStatic diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/api/StatusForPost.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/api/mastodon/StatusForPost.kt similarity index 57% rename from src/main/kotlin/dev/usbharu/hideout/domain/model/api/StatusForPost.kt rename to src/main/kotlin/dev/usbharu/hideout/domain/model/api/mastodon/StatusForPost.kt index b89a0516..bc1f7dfc 100644 --- a/src/main/kotlin/dev/usbharu/hideout/domain/model/api/StatusForPost.kt +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/api/mastodon/StatusForPost.kt @@ -1,4 +1,4 @@ -package dev.usbharu.hideout.domain.model.api +package dev.usbharu.hideout.domain.model.api.mastodon data class StatusForPost( val status: String, diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/PostCreateDto.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/PostCreateDto.kt new file mode 100644 index 00000000..1869da21 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/PostCreateDto.kt @@ -0,0 +1,12 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility + +data class PostCreateDto( + val text: String, + val overview: String? = null, + val visibility: Visibility = Visibility.PUBLIC, + val repostId: Long? = null, + val repolyId: Long? = null, + val userId: Long +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/UserResponse.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/UserResponse.kt new file mode 100644 index 00000000..ab8c1829 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/UserResponse.kt @@ -0,0 +1,27 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +import dev.usbharu.hideout.domain.model.hideout.entity.User + +data class UserResponse( + val id: Long, + val name: String, + val domain: String, + val screenName: String, + val description: String = "", + val url: String, + val createdAt: Long +) { + companion object { + fun from(user: User): UserResponse { + return UserResponse( + id = user.id, + name = user.name, + domain = user.domain, + screenName = user.screenName, + description = user.description, + url = user.url, + createdAt = user.createdAt.toEpochMilli() + ) + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Post.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Post.kt new file mode 100644 index 00000000..2cfb45be --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Post.kt @@ -0,0 +1,13 @@ +package dev.usbharu.hideout.domain.model.hideout.entity + +data class Post( + val id: Long, + val userId: Long, + val overview: String? = null, + val text: String, + val createdAt: Long, + val visibility: Visibility, + val url: String, + val repostId: Long? = null, + val replyId: Long? = null +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Visibility.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Visibility.kt new file mode 100644 index 00000000..9acf8c16 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Visibility.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.domain.model.hideout.entity + +enum class Visibility { + PUBLIC, + UNLISTED, + FOLLOWERS, + DIRECT +} diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/Post.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/Post.kt new file mode 100644 index 00000000..bc768d32 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/Post.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.domain.model.hideout.form + +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility + +data class Post( + val text: String, + val overview: String? = null, + val visibility: Visibility = Visibility.PUBLIC, + val repostId: Long? = null, + val replyId: Long? = null +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/UserCreate.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/UserCreate.kt new file mode 100644 index 00000000..40d96a92 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/UserCreate.kt @@ -0,0 +1,3 @@ +package dev.usbharu.hideout.domain.model.hideout.form + +data class UserCreate(val username: String, val password: String) diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/PostNotFoundException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/PostNotFoundException.kt new file mode 100644 index 00000000..9fe3cf78 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/PostNotFoundException.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.exception + +class PostNotFoundException : IllegalArgumentException { + constructor() : super() + constructor(s: String?) : super(s) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt index fed45736..2e0c6b1a 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt @@ -3,9 +3,12 @@ package dev.usbharu.hideout.plugins import dev.usbharu.hideout.routing.activitypub.inbox import dev.usbharu.hideout.routing.activitypub.outbox import dev.usbharu.hideout.routing.activitypub.usersAP -import dev.usbharu.hideout.routing.api.v1.statuses +import dev.usbharu.hideout.routing.api.internal.v1.posts +import dev.usbharu.hideout.routing.api.internal.v1.users +import dev.usbharu.hideout.routing.api.mastodon.v1.statuses import dev.usbharu.hideout.routing.wellknown.webfinger import dev.usbharu.hideout.service.IPostService +import dev.usbharu.hideout.service.IUserApiService import dev.usbharu.hideout.service.activitypub.ActivityPubService import dev.usbharu.hideout.service.activitypub.ActivityPubUserService import dev.usbharu.hideout.service.impl.IUserService @@ -14,12 +17,14 @@ import io.ktor.server.application.* import io.ktor.server.plugins.autohead.* import io.ktor.server.routing.* +@Suppress("LongParameterList") fun Application.configureRouting( httpSignatureVerifyService: HttpSignatureVerifyService, activityPubService: ActivityPubService, userService: IUserService, activityPubUserService: ActivityPubUserService, - postService: IPostService + postService: IPostService, + userApiService: IUserApiService ) { install(AutoHeadResponse) routing { @@ -31,5 +36,9 @@ fun Application.configureRouting( route("/api/v1") { statuses(postService) } + route("/api/internal/v1") { + posts(postService) + users(userService, userApiService) + } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt index 939fe5ba..b6170270 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt @@ -35,11 +35,14 @@ fun Application.configureSecurity( acceptLeeway(3) } validate { jwtCredential -> - if (jwtCredential.payload.getClaim("username")?.asString().isNullOrBlank().not()) { - JWTPrincipal(jwtCredential.payload) - } else { - null + val uid = jwtCredential.payload.getClaim("uid") + if (uid.isMissing) { + return@validate null } + if (uid.asLong() == null) { + return@validate null + } + return@validate JWTPrincipal(jwtCredential.payload) } } } @@ -73,8 +76,8 @@ fun Application.configureSecurity( } authenticate(TOKEN_AUTH) { get("/auth-check") { - val principal = call.principal() - val username = principal!!.payload.getClaim("username") + val principal = call.principal() ?: throw IllegalStateException("no principal") + val username = principal.payload.getClaim("uid") call.respondText("Hello $username") } } diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/IPostRepository.kt b/src/main/kotlin/dev/usbharu/hideout/repository/IPostRepository.kt index 9fee2d02..38223a97 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/IPostRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/IPostRepository.kt @@ -1,10 +1,10 @@ package dev.usbharu.hideout.repository -import dev.usbharu.hideout.domain.model.Post -import dev.usbharu.hideout.domain.model.PostEntity +import dev.usbharu.hideout.domain.model.hideout.entity.Post interface IPostRepository { - suspend fun insert(post: Post): PostEntity - suspend fun findOneById(id: Long): PostEntity + suspend fun generateId(): Long + suspend fun save(post: Post): Post + suspend fun findOneById(id: Long): Post suspend fun delete(id: Long) } diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt index 9a57f4e7..45101190 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt @@ -1,10 +1,7 @@ package dev.usbharu.hideout.repository -import dev.usbharu.hideout.config.Config -import dev.usbharu.hideout.domain.model.Post -import dev.usbharu.hideout.domain.model.PostEntity -import dev.usbharu.hideout.domain.model.Posts -import dev.usbharu.hideout.domain.model.toPost +import dev.usbharu.hideout.domain.model.hideout.entity.Post +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility import dev.usbharu.hideout.service.IdGenerateService import kotlinx.coroutines.Dispatchers import org.jetbrains.exposed.sql.* @@ -22,41 +19,30 @@ class PostRepositoryImpl(database: Database, private val idGenerateService: IdGe } } + override suspend fun generateId(): Long = idGenerateService.generateId() + @Suppress("InjectDispatcher") suspend fun query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } - override suspend fun insert(post: Post): PostEntity { + override suspend fun save(post: Post): Post { return query { - val generateId = idGenerateService.generateId() - val name = Users.select { Users.id eq post.userId }.single().toUser().name - val postUrl = Config.configData.url + "/users/$name/posts/$generateId" Posts.insert { - it[id] = generateId + it[id] = post.id it[userId] = post.userId it[overview] = post.overview it[text] = post.text it[createdAt] = post.createdAt - it[visibility] = post.visibility - it[url] = postUrl + it[visibility] = post.visibility.ordinal + it[url] = post.url it[repostId] = post.repostId it[replyId] = post.replyId } - return@query PostEntity( - id = generateId, - userId = post.userId, - overview = post.overview, - text = post.text, - createdAt = post.createdAt, - visibility = post.visibility, - url = postUrl, - repostId = post.repostId, - replyId = post.replyId - ) + return@query post } } - override suspend fun findOneById(id: Long): PostEntity { + override suspend fun findOneById(id: Long): Post { return query { Posts.select { Posts.id eq id }.single().toPost() } @@ -68,3 +54,30 @@ class PostRepositoryImpl(database: Database, private val idGenerateService: IdGe } } } + +object Posts : Table() { + val id = long("id") + val userId = long("userId").references(Users.id) + val overview = varchar("overview", 100).nullable() + val text = varchar("text", 3000) + val createdAt = long("createdAt") + val visibility = integer("visibility").default(0) + val url = varchar("url", 500) + val repostId = long("repostId").references(id).nullable() + val replyId = long("replyId").references(id).nullable() + override val primaryKey: PrimaryKey = PrimaryKey(id) +} + +fun ResultRow.toPost(): Post { + return Post( + id = this[Posts.id], + userId = this[Posts.userId], + overview = this[Posts.overview], + text = this[Posts.text], + createdAt = this[Posts.createdAt], + visibility = Visibility.values().first { visibility -> visibility.ordinal == this[Posts.visibility] }, + url = this[Posts.url], + repostId = this[Posts.repostId], + replyId = this[Posts.replyId] + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/UserRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/UserRouting.kt index 1814c62e..a22dc3cc 100644 --- a/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/UserRouting.kt +++ b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/UserRouting.kt @@ -26,7 +26,9 @@ fun Routing.usersAP(activityPubUserService: ActivityPubUserService, userService: ) } get { - val userEntity = userService.findByNameLocalUser(call.parameters["name"]!!) + val userEntity = userService.findByNameLocalUser( + call.parameters["name"] ?: throw ParameterNotExistException("Parameter(name='name') does not exist.") + ) call.respondText(userEntity.toString() + "\n" + userService.findFollowersById(userEntity.id)) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/api/internal/v1/Posts.kt b/src/main/kotlin/dev/usbharu/hideout/routing/api/internal/v1/Posts.kt new file mode 100644 index 00000000..e91c5b93 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/routing/api/internal/v1/Posts.kt @@ -0,0 +1,95 @@ +package dev.usbharu.hideout.routing.api.internal.v1 + +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto +import dev.usbharu.hideout.domain.model.hideout.form.Post +import dev.usbharu.hideout.exception.ParameterNotExistException +import dev.usbharu.hideout.exception.PostNotFoundException +import dev.usbharu.hideout.plugins.TOKEN_AUTH +import dev.usbharu.hideout.service.IPostService +import dev.usbharu.hideout.util.AcctUtil +import dev.usbharu.hideout.util.InstantParseUtil +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +@Suppress("LongMethod") +fun Route.posts(postService: IPostService) { + route("/posts") { + authenticate(TOKEN_AUTH) { + post { + val principal = call.principal() ?: throw IllegalStateException("no principal") + val userId = principal.payload.getClaim("uid").asLong() + + val receive = call.receive() + val postCreateDto = PostCreateDto( + text = receive.text, + overview = receive.overview, + visibility = receive.visibility, + repostId = receive.repostId, + repolyId = receive.replyId, + userId = userId + ) + val create = postService.create(postCreateDto) + call.response.header("Location", create.url) + call.respond(HttpStatusCode.OK) + } + } + authenticate(TOKEN_AUTH, optional = true) { + get { + val userId = call.principal()?.payload?.getClaim("uid")?.asLong() + val since = InstantParseUtil.parse(call.request.queryParameters["since"]) + val until = InstantParseUtil.parse(call.request.queryParameters["until"]) + val minId = call.request.queryParameters["minId"]?.toLong() + val maxId = call.request.queryParameters["maxId"]?.toLong() + val limit = call.request.queryParameters["limit"]?.toInt() + call.respond(HttpStatusCode.OK, postService.findAll(since, until, minId, maxId, limit, userId)) + } + get("/{id}") { + val userId = call.principal()?.payload?.getClaim("uid")?.asLong() + val id = call.parameters["id"]?.toLong() + ?: throw ParameterNotExistException("Parameter(id='postsId') does not exist.") + val post = ( + postService.findByIdForUser(id, userId) + ?: throw PostNotFoundException("$id was not found or is not authorized.") + ) + call.respond(post) + } + } + } + route("/users/{name}/posts") { + authenticate(TOKEN_AUTH, optional = true) { + get { + val userId = call.principal()?.payload?.getClaim("uid")?.asLong() + val targetUserName = call.parameters["name"] + ?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.") + val targetUserId = targetUserName.toLongOrNull() + val posts = if (targetUserId == null) { + val acct = AcctUtil.parse(targetUserName) + postService.findByUserNameAndDomainForUser( + acct.username, + acct.domain ?: Config.configData.domain, + forUserId = userId + ) + } else { + postService.findByUserIdForUser(targetUserId, forUserId = userId) + } + call.respond(posts) + } + get("/{id}") { + val userId = call.principal()?.payload?.getClaim("uid")?.asLong() + val id = call.parameters["id"]?.toLong() + ?: throw ParameterNotExistException("Parameter(name='postsId' does not exist.") + val post = ( + postService.findByIdForUser(id, userId) + ?: throw PostNotFoundException("$id was not found or is not authorized.") + ) + call.respond(post) + } + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/api/internal/v1/Users.kt b/src/main/kotlin/dev/usbharu/hideout/routing/api/internal/v1/Users.kt new file mode 100644 index 00000000..5a51c4c2 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/routing/api/internal/v1/Users.kt @@ -0,0 +1,105 @@ +package dev.usbharu.hideout.routing.api.internal.v1 + +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.hideout.dto.UserCreateDto +import dev.usbharu.hideout.domain.model.hideout.form.UserCreate +import dev.usbharu.hideout.exception.ParameterNotExistException +import dev.usbharu.hideout.plugins.TOKEN_AUTH +import dev.usbharu.hideout.service.IUserApiService +import dev.usbharu.hideout.service.impl.IUserService +import dev.usbharu.hideout.util.AcctUtil +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +@Suppress("LongMethod") +fun Route.users(userService: IUserService, userApiService: IUserApiService) { + route("/users") { + get { + call.respond(userApiService.findAll()) + } + post { + val userCreate = call.receive() + if (userService.usernameAlreadyUse(userCreate.username)) { + return@post call.respond(HttpStatusCode.BadRequest) + } + val user = userService.createLocalUser( + UserCreateDto( + userCreate.username, + userCreate.username, + "", + userCreate.password + ) + ) + call.response.header("Location", "${Config.configData.url}/api/internal/v1/users/${user.name}") + call.respond(HttpStatusCode.Created) + } + route("/{name}") { + authenticate(TOKEN_AUTH, optional = true) { + get { + val userParameter = ( + call.parameters["name"] + ?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.") + ) + if (userParameter.toLongOrNull() != null) { + return@get call.respond(userApiService.findById(userParameter.toLong())) + } else { + val acct = AcctUtil.parse(userParameter) + return@get call.respond(userApiService.findByAcct(acct)) + } + } + } + + route("/followers") { + get { + val userParameter = call.parameters["name"] + ?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.") + if (userParameter.toLongOrNull() != null) { + return@get call.respond(userApiService.findFollowers(userParameter.toLong())) + } + val acct = AcctUtil.parse(userParameter) + return@get call.respond(userApiService.findFollowersByAcct(acct)) + } + authenticate(TOKEN_AUTH) { + post { + val userId = call.principal()?.payload?.getClaim("uid")?.asLong() + ?: throw IllegalStateException("no principal") + val userParameter = call.parameters["name"] + ?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.") + if (userParameter.toLongOrNull() != null) { + if (userService.addFollowers(userParameter.toLong(), userId)) { + return@post call.respond(HttpStatusCode.OK) + } else { + return@post call.respond(HttpStatusCode.Accepted) + } + } + val acct = AcctUtil.parse(userParameter) + val targetUser = userApiService.findByAcct(acct) + if (userService.addFollowers(targetUser.id, userId)) { + return@post call.respond(HttpStatusCode.OK) + } else { + return@post call.respond(HttpStatusCode.Accepted) + } + } + } + } + route("/following") { + get { + val userParameter = ( + call.parameters["name"] + ?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.") + ) + if (userParameter.toLongOrNull() != null) { + return@get call.respond(userApiService.findFollowings(userParameter.toLong())) + } + val acct = AcctUtil.parse(userParameter) + return@get call.respond(userApiService.findFollowingsByAcct(acct)) + } + } + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/api/mastodon/v1/Statuses.kt b/src/main/kotlin/dev/usbharu/hideout/routing/api/mastodon/v1/Statuses.kt new file mode 100644 index 00000000..2bd31725 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/routing/api/mastodon/v1/Statuses.kt @@ -0,0 +1,21 @@ +package dev.usbharu.hideout.routing.api.mastodon.v1 + +import dev.usbharu.hideout.service.IPostService +import io.ktor.server.routing.* + +@Suppress("UnusedPrivateMember") +fun Route.statuses(postService: IPostService) { +// route("/statuses") { +// post { +// val status: StatusForPost = call.receive() +// val post = dev.usbharu.hideout.domain.model.hideout.form.Post( +// userId = status.userId, +// createdAt = System.currentTimeMillis(), +// text = status.status, +// visibility = 1 +// ) +// postService.create(post) +// call.respond(status) +// } +// } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/api/v1/Statuses.kt b/src/main/kotlin/dev/usbharu/hideout/routing/api/v1/Statuses.kt deleted file mode 100644 index e6c08942..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/routing/api/v1/Statuses.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.usbharu.hideout.routing.api.v1 - -import dev.usbharu.hideout.domain.model.Post -import dev.usbharu.hideout.domain.model.api.StatusForPost -import dev.usbharu.hideout.service.IPostService -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* - -fun Route.statuses(postService: IPostService) { - route("/statuses") { - post { - val status: StatusForPost = call.receive() - val post = Post( - userId = status.userId, - createdAt = System.currentTimeMillis(), - text = status.status, - visibility = 1 - ) - postService.create(post) - call.respond(status) - } - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/IPostService.kt b/src/main/kotlin/dev/usbharu/hideout/service/IPostService.kt index b523cb1e..e4a33550 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/IPostService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/IPostService.kt @@ -1,7 +1,61 @@ package dev.usbharu.hideout.service -import dev.usbharu.hideout.domain.model.Post +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto +import dev.usbharu.hideout.domain.model.hideout.entity.Post +import java.time.Instant +@Suppress("LongParameterList") interface IPostService { - suspend fun create(post: Post) + suspend fun create(post: Post): Post + suspend fun create(post: PostCreateDto): Post + suspend fun findAll( + since: Instant? = null, + until: Instant? = null, + minId: Long? = null, + maxId: Long? = null, + limit: Int? = 10, + userId: Long? = null + ): List + + suspend fun findById(id: String): Post + + /** + * 権限を考慮して投稿を取得します。 + * + * @param id + * @param userId + * @return + */ + suspend fun findByIdForUser(id: Long, userId: Long?): Post? + + /** + * 権限を考慮してユーザーの投稿を取得します。 + * + * @param userId + * @param forUserId + * @return + */ + suspend fun findByUserIdForUser( + userId: Long, + since: Instant? = null, + until: Instant? = null, + minId: Long? = null, + maxId: Long? = null, + limit: Int? = null, + forUserId: Long? = null + ): List + + suspend fun findByUserNameAndDomainForUser( + userName: String, + domain: String = Config.configData.domain, + since: Instant? = null, + until: Instant? = null, + minId: Long? = null, + maxId: Long? = null, + limit: Int? = null, + forUserId: Long? = null + ): List + + suspend fun delete(id: String) } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/IUserApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/IUserApiService.kt new file mode 100644 index 00000000..dd79e471 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/IUserApiService.kt @@ -0,0 +1,24 @@ +package dev.usbharu.hideout.service + +import dev.usbharu.hideout.domain.model.Acct +import dev.usbharu.hideout.domain.model.hideout.dto.UserResponse + +interface IUserApiService { + suspend fun findAll(limit: Int? = 100, offset: Long = 0): List + + suspend fun findById(id: Long): UserResponse + + suspend fun findByIds(ids: List): List + + suspend fun findByAcct(acct: Acct): UserResponse + + suspend fun findByAccts(accts: List): List + + suspend fun findFollowers(userId: Long): List + + suspend fun findFollowings(userId: Long): List + + suspend fun findFollowersByAcct(acct: Acct): List + + suspend fun findFollowingsByAcct(acct: Acct): List +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/JwtServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/JwtServiceImpl.kt index e8519f2c..4dea9bb1 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/JwtServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/JwtServiceImpl.kt @@ -52,7 +52,7 @@ class JwtServiceImpl( .withAudience("${Config.configData.url}/users/${user.name}") .withIssuer(Config.configData.url) .withKeyId(keyId.await().toString()) - .withClaim("username", user.name) + .withClaim("uid", user.id) .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) .sign(Algorithm.RSA256(publicKey.await(), privateKey.await())) diff --git a/src/main/kotlin/dev/usbharu/hideout/service/ServerInitialiseServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/ServerInitialiseServiceImpl.kt index 78bc0192..13fabeaf 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/ServerInitialiseServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/ServerInitialiseServiceImpl.kt @@ -25,7 +25,7 @@ class ServerInitialiseServiceImpl(private val metaRepository: IMetaRepository) : return } - if (isVersionChanged(savedMeta!!)) { + if (isVersionChanged(requireNotNull(savedMeta))) { logger.info("Version changed!! (${savedMeta.version} -> $implementationVersion)") updateVersion(savedMeta, implementationVersion) } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/UserApiServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/UserApiServiceImpl.kt new file mode 100644 index 00000000..f3e70e10 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/UserApiServiceImpl.kt @@ -0,0 +1,38 @@ +package dev.usbharu.hideout.service + +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.Acct +import dev.usbharu.hideout.domain.model.hideout.dto.UserResponse +import dev.usbharu.hideout.service.impl.IUserService +import org.koin.core.annotation.Single + +@Single +class UserApiServiceImpl(private val userService: IUserService) : IUserApiService { + override suspend fun findAll(limit: Int?, offset: Long): List = + userService.findAll(limit, offset).map { UserResponse.from(it) } + + override suspend fun findById(id: Long): UserResponse = UserResponse.from(userService.findById(id)) + + override suspend fun findByIds(ids: List): List = + userService.findByIds(ids).map { UserResponse.from(it) } + + override suspend fun findByAcct(acct: Acct): UserResponse = + UserResponse.from(userService.findByNameAndDomain(acct.username, acct.domain)) + + override suspend fun findByAccts(accts: List): List { + return userService.findByNameAndDomains(accts.map { it.username to (it.domain ?: Config.configData.domain) }) + .map { UserResponse.from(it) } + } + + override suspend fun findFollowers(userId: Long): List = + userService.findFollowersById(userId).map { UserResponse.from(it) } + + override suspend fun findFollowings(userId: Long): List = + userService.findFollowingById(userId).map { UserResponse.from(it) } + + override suspend fun findFollowersByAcct(acct: Acct): List = + userService.findFollowersByNameAndDomain(acct.username, acct.domain).map { UserResponse.from(it) } + + override suspend fun findFollowingsByAcct(acct: Acct): List = + userService.findFollowingByNameAndDomain(acct.username, acct.domain).map { UserResponse.from(it) } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteService.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteService.kt index 33ba42cc..5c6ccf96 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteService.kt @@ -1,11 +1,11 @@ package dev.usbharu.hideout.service.activitypub -import dev.usbharu.hideout.domain.model.PostEntity +import dev.usbharu.hideout.domain.model.hideout.entity.Post import dev.usbharu.hideout.domain.model.job.DeliverPostJob import kjob.core.job.JobProps interface ActivityPubNoteService { - suspend fun createNote(post: PostEntity) + suspend fun createNote(post: Post) suspend fun createNoteJob(props: JobProps) } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImpl.kt index 401367d7..42f0df20 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImpl.kt @@ -2,9 +2,9 @@ package dev.usbharu.hideout.service.activitypub import com.fasterxml.jackson.module.kotlin.readValue import dev.usbharu.hideout.config.Config -import dev.usbharu.hideout.domain.model.PostEntity import dev.usbharu.hideout.domain.model.ap.Create import dev.usbharu.hideout.domain.model.ap.Note +import dev.usbharu.hideout.domain.model.hideout.entity.Post import dev.usbharu.hideout.domain.model.job.DeliverPostJob import dev.usbharu.hideout.plugins.postAp import dev.usbharu.hideout.service.impl.IUserService @@ -24,7 +24,7 @@ class ActivityPubNoteServiceImpl( private val logger = LoggerFactory.getLogger(this::class.java) - override suspend fun createNote(post: PostEntity) { + override suspend fun createNote(post: Post) { val followers = userService.findFollowersById(post.userId) val userEntity = userService.findById(post.userId) val note = Config.configData.objectMapper.writeValueAsString(post) @@ -39,7 +39,7 @@ class ActivityPubNoteServiceImpl( override suspend fun createNoteJob(props: JobProps) { val actor = props[DeliverPostJob.actor] - val postEntity = Config.configData.objectMapper.readValue(props[DeliverPostJob.post]) + val postEntity = Config.configData.objectMapper.readValue(props[DeliverPostJob.post]) val note = Note( name = "Note", id = postEntity.url, diff --git a/src/main/kotlin/dev/usbharu/hideout/service/impl/IUserService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/IUserService.kt index d2d61b1b..ab726ac1 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/impl/IUserService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/IUserService.kt @@ -16,6 +16,8 @@ interface IUserService { suspend fun findByNameLocalUser(name: String): User + suspend fun findByNameAndDomain(name: String, domain: String? = null): User + suspend fun findByNameAndDomains(names: List>): List suspend fun findByUrl(url: String): User @@ -30,5 +32,18 @@ interface IUserService { suspend fun findFollowersById(id: Long): List - suspend fun addFollowers(id: Long, follower: Long) + suspend fun findFollowersByNameAndDomain(name: String, domain: String?): List + + suspend fun findFollowingById(id: Long): List + + suspend fun findFollowingByNameAndDomain(name: String, domain: String?): List + + /** + * フォロワーを追加する + * + * @param id + * @param follower + * @return リクエストが成功したか + */ + suspend fun addFollowers(id: Long, follower: Long): Boolean } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/impl/PostService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/PostService.kt index 71dd6be3..cbb7a3bb 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/impl/PostService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/PostService.kt @@ -1,23 +1,130 @@ package dev.usbharu.hideout.service.impl -import dev.usbharu.hideout.domain.model.Post +import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto +import dev.usbharu.hideout.domain.model.hideout.entity.Post +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility import dev.usbharu.hideout.repository.IPostRepository +import dev.usbharu.hideout.repository.Posts +import dev.usbharu.hideout.repository.UsersFollowers +import dev.usbharu.hideout.repository.toPost import dev.usbharu.hideout.service.IPostService import dev.usbharu.hideout.service.activitypub.ActivityPubNoteService +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.inSubQuery +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.orIfNotNull +import org.jetbrains.exposed.sql.orWhere +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction import org.koin.core.annotation.Single import org.slf4j.LoggerFactory +import java.time.Instant @Single class PostService( private val postRepository: IPostRepository, - private val activityPubNoteService: ActivityPubNoteService + private val activityPubNoteService: ActivityPubNoteService, + private val userService: IUserService ) : IPostService { private val logger = LoggerFactory.getLogger(this::class.java) - override suspend fun create(post: Post) { + override suspend fun create(post: Post): Post { logger.debug("create post={}", post) - val postEntity = postRepository.insert(post) + val postEntity = postRepository.save(post) activityPubNoteService.createNote(postEntity) + return post + } + + override suspend fun create(post: PostCreateDto): Post { + logger.debug("create post={}", post) + val user = userService.findById(post.userId) + val id = postRepository.generateId() + val postEntity = Post( + id = id, + userId = user.id, + overview = null, + text = post.text, + createdAt = Instant.now().toEpochMilli(), + visibility = Visibility.PUBLIC, + url = "${user.url}/posts/$id", + repostId = null, + replyId = null + ) + postRepository.save(postEntity) + return postEntity + } + + override suspend fun findAll( + since: Instant?, + until: Instant?, + minId: Long?, + maxId: Long?, + limit: Int?, + userId: Long? + ): List { + return transaction { + val select = Posts.select { + Posts.visibility.eq(Visibility.PUBLIC.ordinal) + } + if (userId != null) { + select.orWhere { + Posts.userId.inSubQuery( + UsersFollowers.slice(UsersFollowers.userId).select(UsersFollowers.followerId eq userId) + ) + } + } + select.map { it.toPost() } + } + } + + override suspend fun findById(id: String): Post { + TODO("Not yet implemented") + } + + override suspend fun findByIdForUser(id: Long, userId: Long?): Post? { + return transaction { + val select = Posts.select( + Posts.id.eq(id).and( + Posts.visibility.eq(Visibility.PUBLIC.ordinal).orIfNotNull( + userId?.let { + Posts.userId.inSubQuery( + UsersFollowers.slice(UsersFollowers.userId).select(UsersFollowers.followerId.eq(userId)) + ) + } + ) + ) + ) + select.singleOrNull()?.toPost() + } + } + + override suspend fun findByUserIdForUser( + userId: Long, + since: Instant?, + until: Instant?, + minId: Long?, + maxId: Long?, + limit: Int?, + forUserId: Long? + ): List { + TODO("Not yet implemented") + } + + override suspend fun findByUserNameAndDomainForUser( + userName: String, + domain: String, + since: Instant?, + until: Instant?, + minId: Long?, + maxId: Long?, + limit: Int?, + forUserId: Long? + ): List { + TODO("Not yet implemented") + } + + override suspend fun delete(id: String) { + TODO("Not yet implemented") } } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/impl/UserService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserService.kt index 599e274f..14f7b04b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/impl/UserService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserService.kt @@ -35,6 +35,11 @@ class UserService(private val userRepository: IUserRepository, private val userA ?: throw UserNotFoundException("$name was not found.") } + override suspend fun findByNameAndDomain(name: String, domain: String?): User { + return userRepository.findByNameAndDomain(name, domain ?: Config.configData.domain) + ?: throw UserNotFoundException("$name was not found.") + } + override suspend fun findByNameAndDomains(names: List>): List = userRepository.findByNameAndDomains(names) @@ -87,6 +92,21 @@ class UserService(private val userRepository: IUserRepository, private val userA } override suspend fun findFollowersById(id: Long): List = userRepository.findFollowersById(id) + override suspend fun findFollowersByNameAndDomain(name: String, domain: String?): List { + TODO("Not yet implemented") + } - override suspend fun addFollowers(id: Long, follower: Long) = userRepository.createFollower(id, follower) + override suspend fun findFollowingById(id: Long): List { + TODO("Not yet implemented") + } + + override suspend fun findFollowingByNameAndDomain(name: String, domain: String?): List { + TODO("Not yet implemented") + } + + // TODO APのフォロー処理を作る + override suspend fun addFollowers(id: Long, follower: Long): Boolean { + userRepository.createFollower(id, follower) + return false + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/util/AcctUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/AcctUtil.kt new file mode 100644 index 00000000..34ab88ba --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/AcctUtil.kt @@ -0,0 +1,42 @@ +package dev.usbharu.hideout.util + +import dev.usbharu.hideout.domain.model.Acct + +object AcctUtil { + fun parse(string: String): Acct { + if (string.isBlank()) { + throw IllegalArgumentException("Invalid acct.(Blank)") + } + return when (string.count { c -> c == '@' }) { + 0 -> { + Acct(string) + } + + 1 -> { + if (string.startsWith("@")) { + Acct(string.substring(1 until string.length)) + } else { + Acct(string.substringBefore("@"), string.substringAfter("@")) + } + } + + 2 -> { + if (string.startsWith("@")) { + val substring = string.substring(1 until string.length) + val userName = substring.substringBefore("@") + val domain = substring.substringAfter("@") + Acct( + userName, + domain.ifBlank { null } + ) + } else { + throw IllegalArgumentException("Invalid acct.(@ are in the wrong position)") + } + } + + else -> { + throw IllegalArgumentException("Invalid acct.(Too many @)") + } + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/util/InstantParseUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/InstantParseUtil.kt new file mode 100644 index 00000000..66da1b60 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/InstantParseUtil.kt @@ -0,0 +1,18 @@ +package dev.usbharu.hideout.util + +import java.time.Instant +import java.time.format.DateTimeParseException + +object InstantParseUtil { + fun parse(str: String?): Instant? { + return try { + Instant.ofEpochMilli(str?.toLong() ?: return null) + } catch (e: NumberFormatException) { + try { + Instant.parse(str) + } catch (e: DateTimeParseException) { + null + } + } + } +} diff --git a/src/main/resources/openapi/documentation.yaml b/src/main/resources/openapi/documentation.yaml new file mode 100644 index 00000000..df2efd03 --- /dev/null +++ b/src/main/resources/openapi/documentation.yaml @@ -0,0 +1,572 @@ +openapi: "3.0.3" +info: + title: "hideout API" + description: "hideout API" + version: "1.0.0" +servers: + - url: "https://hideout" +paths: + /.well-known/jwks.json: + get: + description: "" + responses: + "200": + description: "OK" + content: + application/json: + schema: + type: "string" + /auth-check: + get: + description: "" + responses: + "200": + description: "OK" + content: + text/plain: + schema: + type: "string" + examples: + Example#1: + value: "" + /login: + post: + description: "" + requestBody: + content: + '*/*': + schema: + $ref: "#/components/schemas/UserLogin" + required: true + responses: + "401": + description: "Unauthorized" + content: + '*/*': + schema: + type: "object" + "200": + description: "OK" + content: + '*/*': + schema: + $ref: "#/components/schemas/JwtToken" + /refresh-token: + post: + description: "" + requestBody: + content: + '*/*': + schema: + $ref: "#/components/schemas/RefreshToken" + required: true + responses: + "200": + description: "OK" + content: + '*/*': + schema: + $ref: "#/components/schemas/JwtToken" + /.well-known/webfinger: + get: + description: "" + parameters: + - name: "resource" + in: "query" + required: false + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + $ref: "#/components/schemas/WebFinger" + /api/internal/v1/posts: + get: + description: "" + parameters: + - name: "since" + in: "query" + required: false + schema: + type: "string" + - name: "until" + in: "query" + required: false + schema: + type: "string" + - name: "minId" + in: "query" + required: false + schema: + type: "number" + - name: "maxId" + in: "query" + required: false + schema: + type: "number" + - name: "limit" + in: "query" + required: false + schema: + type: "integer" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "array" + items: + $ref: "#/components/schemas/Post" + post: + description: "" + requestBody: + content: + '*/*': + schema: + $ref: "#/components/schemas/Post" + required: true + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "object" + /api/internal/v1/posts/{id}: + get: + description: "" + parameters: + - name: "id" + in: "path" + required: true + schema: + type: "number" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + $ref: "#/components/schemas/Post" + /api/internal/v1/users: + get: + description: "" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "array" + items: + $ref: "#/components/schemas/UserResponse" + post: + description: "" + requestBody: + content: + '*/*': + schema: + $ref: "#/components/schemas/UserCreate" + required: true + responses: + "400": + description: "Bad Request" + content: + '*/*': + schema: + type: "object" + "201": + description: "Created" + content: + '*/*': + schema: + type: "object" + /api/internal/v1/users/{name}: + get: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "object" + /api/internal/v1/users/{name}/followers: + get: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "array" + items: + type: "object" + post: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "object" + "202": + description: "Accepted" + content: + '*/*': + schema: + type: "object" + /api/internal/v1/users/{name}/following: + get: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "array" + items: + type: "object" + /api/internal/v1/users/{name}/posts: + get: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "array" + items: + $ref: "#/components/schemas/Post" + /api/internal/v1/users/{name}/posts/{id}: + get: + description: "" + parameters: + - name: "id" + in: "path" + required: true + schema: + type: "number" + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + $ref: "#/components/schemas/Post" + /inbox: + get: + description: "" + responses: + "405": + description: "Method Not Allowed" + content: + '*/*': + schema: + type: "object" + post: + description: "" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "string" + "501": + description: "Not Implemented" + content: + '*/*': + schema: + type: "object" + /outbox: + get: + description: "" + responses: + "501": + description: "Not Implemented" + content: + '*/*': + schema: + type: "object" + post: + description: "" + responses: + "501": + description: "Not Implemented" + content: + '*/*': + schema: + type: "object" + /users/{name}: + get: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + text/plain: + schema: + type: "string" + /users/{name}/inbox: + get: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "405": + description: "Method Not Allowed" + content: + '*/*': + schema: + type: "object" + post: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "string" + "501": + description: "Not Implemented" + content: + '*/*': + schema: + type: "object" + /users/{name}/outbox: + get: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "501": + description: "Not Implemented" + content: + '*/*': + schema: + type: "object" + post: + description: "" + parameters: + - name: "name" + in: "path" + required: true + schema: + type: "string" + responses: + "501": + description: "Not Implemented" + content: + '*/*': + schema: + type: "object" + /: + get: + description: "" + responses: + "200": + description: "OK" + content: + text/html: + schema: + type: "string" + /register: + get: + description: "" + responses: + "200": + description: "OK" + content: + text/html: + schema: + type: "string" + post: + description: "" + parameters: + - name: "password" + in: "query" + required: false + schema: + type: "string" + - name: "username" + in: "query" + required: false + schema: + type: "string" + responses: + "200": + description: "OK
Redirect" + content: + text/plain: + schema: + type: "string" + examples: + Example#1: + value: "" + Example#2: + value: "/register" + Example#3: + value: "/register" + Example#4: + value: "/register" +components: + schemas: + UserLogin: + type: "object" + properties: + username: + type: "string" + password: + type: "string" + JwtToken: + type: "object" + properties: + token: + type: "string" + refreshToken: + type: "string" + RefreshToken: + type: "object" + properties: + refreshToken: + type: "string" + Link: + type: "object" + properties: + rel: + type: "string" + type: + type: "string" + href: + type: "string" + WebFinger: + type: "object" + properties: + subject: + type: "string" + links: + type: "array" + items: + $ref: "#/components/schemas/Link" + Post: + type: "object" + properties: + id: + type: "integer" + format: "int64" + userId: + type: "integer" + format: "int64" + overview: + type: "string" + text: + type: "string" + createdAt: + type: "integer" + format: "int64" + visibility: + type: "string" + enum: + - "PUBLIC" + - "UNLISTED" + - "FOLLOWERS" + - "DIRECT" + url: + type: "string" + repostId: + type: "integer" + format: "int64" + replyId: + type: "integer" + format: "int64" + UserResponse: + type: "object" + properties: + id: + type: "integer" + format: "int64" + name: + type: "string" + domain: + type: "string" + screenName: + type: "string" + description: + type: "string" + url: + type: "string" + createdAt: + type: "integer" + format: "int64" + UserCreate: + type: "object" + properties: + username: + type: "string" + password: + type: "string" diff --git a/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt b/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt index 9c558f36..9346fa27 100644 --- a/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt @@ -217,7 +217,7 @@ class SecurityKtTest { .withAudience("${Config.configData.url}/users/test") .withIssuer(Config.configData.url) .withKeyId(kid.toString()) - .withClaim("username", "test") + .withClaim("uid", 123456L) .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) val metaService = mock { @@ -255,7 +255,7 @@ class SecurityKtTest { header("Authorization", "Bearer $token") }.apply { assertEquals(HttpStatusCode.OK, call.response.status) - assertEquals("Hello \"test\"", call.response.bodyAsText()) + assertEquals("Hello 123456", call.response.bodyAsText()) } } @@ -277,7 +277,7 @@ class SecurityKtTest { .withAudience("${Config.configData.url}/users/test") .withIssuer(Config.configData.url) .withKeyId(kid.toString()) - .withClaim("username", "test") + .withClaim("uid", 123345L) .withExpiresAt(now.minus(30, ChronoUnit.MINUTES)) .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) val metaService = mock { @@ -335,7 +335,7 @@ class SecurityKtTest { .withAudience("${Config.configData.url}/users/test") .withIssuer("https://example.com") .withKeyId(kid.toString()) - .withClaim("username", "test") + .withClaim("uid", 12345L) .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) val metaService = mock { @@ -393,7 +393,7 @@ class SecurityKtTest { .withAudience("${Config.configData.url}/users/test") .withIssuer(Config.configData.url) .withKeyId(kid.toString()) - .withClaim("username", "") + .withClaim("uid", null as Long?) .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) val metaService = mock { diff --git a/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRoutingKtTest.kt b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRoutingKtTest.kt index 8e93e159..d1096731 100644 --- a/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRoutingKtTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRoutingKtTest.kt @@ -1,7 +1,6 @@ package dev.usbharu.hideout.routing.activitypub import dev.usbharu.hideout.exception.JsonParseException -import dev.usbharu.hideout.plugins.configureRouting import dev.usbharu.hideout.plugins.configureSerialization import dev.usbharu.hideout.plugins.configureStatusPages import dev.usbharu.hideout.service.activitypub.ActivityPubService @@ -11,6 +10,7 @@ import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService import io.ktor.client.request.* import io.ktor.http.* import io.ktor.server.config.* +import io.ktor.server.routing.* import io.ktor.server.testing.* import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test @@ -27,7 +27,9 @@ class InboxRoutingKtTest { } application { configureSerialization() - configureRouting(mock(), mock(), mock(), mock(), mock()) + routing { + inbox(mock(), mock()) + } } client.get("/inbox").let { Assertions.assertEquals(HttpStatusCode.MethodNotAllowed, it.status) @@ -45,18 +47,14 @@ class InboxRoutingKtTest { val activityPubService = mock { on { parseActivity(any()) } doThrow JsonParseException() } - val userService = mock() - val activityPubUserService = mock() + mock() + mock() application { configureStatusPages() configureSerialization() - configureRouting( - httpSignatureVerifyService, - activityPubService, - userService, - activityPubUserService, - mock() - ) + routing { + inbox(httpSignatureVerifyService, activityPubService) + } } client.post("/inbox").let { Assertions.assertEquals(HttpStatusCode.BadRequest, it.status) @@ -70,7 +68,9 @@ class InboxRoutingKtTest { } application { configureSerialization() - configureRouting(mock(), mock(), mock(), mock(), mock()) + routing { + inbox(mock(), mock()) + } } client.get("/users/test/inbox").let { Assertions.assertEquals(HttpStatusCode.MethodNotAllowed, it.status) @@ -88,18 +88,14 @@ class InboxRoutingKtTest { val activityPubService = mock { on { parseActivity(any()) } doThrow JsonParseException() } - val userService = mock() - val activityPubUserService = mock() + mock() + mock() application { configureStatusPages() configureSerialization() - configureRouting( - httpSignatureVerifyService, - activityPubService, - userService, - activityPubUserService, - mock() - ) + routing { + inbox(httpSignatureVerifyService, activityPubService) + } } client.post("/users/test/inbox").let { Assertions.assertEquals(HttpStatusCode.BadRequest, it.status) diff --git a/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/UsersAPTest.kt b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/UsersAPTest.kt index 03bf53a4..736ea065 100644 --- a/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/UsersAPTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/UsersAPTest.kt @@ -10,18 +10,16 @@ import dev.usbharu.hideout.domain.model.ap.Image import dev.usbharu.hideout.domain.model.ap.Key import dev.usbharu.hideout.domain.model.ap.Person import dev.usbharu.hideout.domain.model.hideout.entity.User -import dev.usbharu.hideout.plugins.configureRouting import dev.usbharu.hideout.plugins.configureSerialization -import dev.usbharu.hideout.service.activitypub.ActivityPubService import dev.usbharu.hideout.service.activitypub.ActivityPubUserService import dev.usbharu.hideout.service.impl.IUserService -import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService import dev.usbharu.hideout.util.HttpUtil.Activity import dev.usbharu.hideout.util.HttpUtil.JsonLd import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.config.* +import io.ktor.server.routing.* import io.ktor.server.testing.* import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.anyString @@ -64,8 +62,6 @@ class UsersAPTest { ) person.context = listOf("https://www.w3.org/ns/activitystreams") - val httpSignatureVerifyService = mock {} - val activityPubService = mock {} val userService = mock {} val activityPubUserService = mock { @@ -74,13 +70,9 @@ class UsersAPTest { application { configureSerialization() - configureRouting( - httpSignatureVerifyService, - activityPubService, - userService, - activityPubUserService, - mock() - ) + routing { + usersAP(activityPubUserService, userService) + } } client.get("/users/test") { accept(ContentType.Application.Activity) @@ -130,8 +122,6 @@ class UsersAPTest { ) person.context = listOf("https://www.w3.org/ns/activitystreams") - val httpSignatureVerifyService = mock {} - val activityPubService = mock {} val userService = mock {} val activityPubUserService = mock { @@ -140,13 +130,9 @@ class UsersAPTest { application { configureSerialization() - configureRouting( - httpSignatureVerifyService, - activityPubService, - userService, - activityPubUserService, - mock() - ) + routing { + usersAP(activityPubUserService, userService) + } } client.get("/users/test") { accept(ContentType.Application.JsonLd) @@ -205,13 +191,9 @@ class UsersAPTest { ) } application { - configureRouting( - mock(), - mock(), - userService, - mock(), - mock() - ) + routing { + usersAP(mock(), userService) + } } client.get("/users/test") { accept(ContentType.Text.Html) diff --git a/src/test/kotlin/dev/usbharu/hideout/routing/api/internal/v1/PostsTest.kt b/src/test/kotlin/dev/usbharu/hideout/routing/api/internal/v1/PostsTest.kt new file mode 100644 index 00000000..901acc89 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/routing/api/internal/v1/PostsTest.kt @@ -0,0 +1,619 @@ +package dev.usbharu.hideout.routing.api.internal.v1 + +import com.auth0.jwt.interfaces.Claim +import com.auth0.jwt.interfaces.Payload +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto +import dev.usbharu.hideout.domain.model.hideout.entity.Post +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility +import dev.usbharu.hideout.plugins.TOKEN_AUTH +import dev.usbharu.hideout.plugins.configureSecurity +import dev.usbharu.hideout.plugins.configureSerialization +import dev.usbharu.hideout.service.IPostService +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.config.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.Test +import org.mockito.kotlin.* +import utils.JsonObjectMapper +import java.time.Instant +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class PostsTest { + + @Test + fun 認証情報無しでpostsにGETしたらPUBLICな投稿一覧が返ってくる() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val posts = listOf( + Post( + id = 12345, + userId = 4321, + text = "test1", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/1" + ), + Post( + id = 123456, + userId = 4322, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + ) + val postService = mock { + onBlocking { + findAll( + since = anyOrNull(), + until = anyOrNull(), + minId = anyOrNull(), + maxId = anyOrNull(), + limit = anyOrNull(), + userId = isNull() + ) + } doReturn posts + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/posts").apply { + assertEquals(HttpStatusCode.OK, status) + assertContentEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun 認証情報ありでpostsにGETすると権限のある投稿が返ってくる() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val claim = mock { + on { asLong() } doReturn 1234 + } + val payload = mock { + on { getClaim(eq("uid")) } doReturn claim + } + + val posts = listOf( + Post( + id = 12345, + userId = 4321, + text = "test1", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/1" + ), + Post( + id = 123456, + userId = 4322, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ), + Post( + id = 1234567, + userId = 4333, + text = "Followers only", + visibility = Visibility.FOLLOWERS, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/3" + ) + ) + + val postService = mock { + onBlocking { + findAll( + since = anyOrNull(), + until = anyOrNull(), + minId = anyOrNull(), + maxId = anyOrNull(), + limit = anyOrNull(), + userId = isNotNull() + ) + } doReturn posts + } + application { + authentication { + bearer(TOKEN_AUTH) { + authenticate { + JWTPrincipal(payload) + } + } + } + configureSerialization() + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + client.get("/api/internal/v1/posts") { + header("Authorization", "Bearer asdkaf") + }.apply { + assertEquals(HttpStatusCode.OK, status) + } + } + + @Test + fun `posts id にGETしたらPUBLICな投稿を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val post = Post( + 12345, 1234, text = "aaa", visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), url = "https://example.com/posts/1" + ) + val postService = mock { + onBlocking { findByIdForUser(any(), anyOrNull()) } doReturn post + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + client.get("/api/internal/v1/posts/1").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `認証情報ありでposts id にGETしたら権限のある投稿を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val post = Post( + 12345, 1234, text = "aaa", visibility = Visibility.FOLLOWERS, + createdAt = Instant.now().toEpochMilli(), url = "https://example.com/posts/1" + ) + val postService = mock { + onBlocking { findByIdForUser(any(), isNotNull()) } doReturn post + } + val claim = mock { + on { asLong() } doReturn 1234 + } + val payload = mock { + on { getClaim(eq("uid")) } doReturn claim + } + application { + configureSerialization() + authentication { + bearer(TOKEN_AUTH) { + authenticate { + JWTPrincipal(payload) + } + } + } + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + client.get("/api/internal/v1/posts/1") { + header("Authorization", "Bearer asdkaf") + }.apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `posts-post postsにpostしたら投稿できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val claim = mock { + on { asLong() } doReturn 1234 + } + val payload = mock { + on { getClaim(eq("uid")) } doReturn claim + } + val postService = mock { + onBlocking { create(any()) } doAnswer { + val argument = it.getArgument(0) + Post( + 123L, + argument.userId, + null, + argument.text, + Instant.now().toEpochMilli(), + Visibility.PUBLIC, + "https://example.com" + ) + } + } + application { + authentication { + + bearer(TOKEN_AUTH) { + authenticate { + println("aaaaaaaaaaaa") + JWTPrincipal(payload) + } + } + } + routing { + route("/api/internal/v1") { + posts(postService) + } + } + configureSerialization() + } + + val post = dev.usbharu.hideout.domain.model.hideout.form.Post("test") + client.post("/api/internal/v1/posts") { + header("Authorization", "Bearer asdkaf") + contentType(ContentType.Application.Json) + setBody(Config.configData.objectMapper.writeValueAsString(post)) + }.apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals("https://example.com", headers["Location"]) + } + argumentCaptor { + verify(postService).create(capture()) + assertEquals(PostCreateDto("test", userId = 1234), firstValue) + } + } + + @Test + fun `users userId postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val posts = listOf( + Post( + id = 12345, + userId = 1, + text = "test1", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/1" + ), + Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + ) + val postService = mock { + onBlocking { + findByUserIdForUser( + userId = any(), + since = anyOrNull(), + until = anyOrNull(), + minId = anyOrNull(), + maxId = anyOrNull(), + limit = anyOrNull(), + forUserId = anyOrNull() + ) + } doReturn posts + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/1/posts").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users username postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val posts = listOf( + Post( + id = 12345, + userId = 1, + text = "test1", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/1" + ), + Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + ) + val postService = mock { + onBlocking { + findByUserNameAndDomainForUser( + userName = eq("test1"), + domain = eq(Config.configData.domain), + since = anyOrNull(), + until = anyOrNull(), + minId = anyOrNull(), + maxId = anyOrNull(), + limit = anyOrNull(), + forUserId = anyOrNull() + ) + } doReturn posts + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/test1/posts").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users username@domain postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val posts = listOf( + Post( + id = 12345, + userId = 1, + text = "test1", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/1" + ), + Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + ) + val postService = mock { + onBlocking { + findByUserNameAndDomainForUser( + userName = eq("test1"), + domain = eq("example.com"), + since = anyOrNull(), + until = anyOrNull(), + minId = anyOrNull(), + maxId = anyOrNull(), + limit = anyOrNull(), + forUserId = anyOrNull() + ) + } doReturn posts + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/test1@example.com/posts").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users @username@domain postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val posts = listOf( + Post( + id = 12345, + userId = 1, + text = "test1", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/1" + ), + Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + ) + val postService = mock { + onBlocking { + findByUserNameAndDomainForUser( + userName = eq("test1"), + domain = eq("example.com"), + since = anyOrNull(), + until = anyOrNull(), + minId = anyOrNull(), + maxId = anyOrNull(), + limit = anyOrNull(), + forUserId = anyOrNull() + ) + } doReturn posts + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/@test1@example.com/posts").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name posts id にGETしたらPUBLICな投稿を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val post = Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + val postService = mock { + onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/test/posts/12345").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users id posts id にGETしたらPUBLICな投稿を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val post = Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + val postService = mock { + onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/1/posts/12345").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name posts id にGETしたらUserIdが間違っててもPUBLICな投稿を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val post = Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + val postService = mock { + onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/423827849732847/posts/12345").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name posts id にGETしたらuserNameが間違っててもPUBLICな投稿を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val post = Post( + id = 123456, + userId = 1, + text = "test2", + visibility = Visibility.PUBLIC, + createdAt = Instant.now().toEpochMilli(), + url = "https://example.com/posts/2" + ) + val postService = mock { + onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + posts(postService) + } + } + } + + client.get("/api/internal/v1/users/invalidUserName/posts/12345").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/routing/api/internal/v1/UsersTest.kt b/src/test/kotlin/dev/usbharu/hideout/routing/api/internal/v1/UsersTest.kt new file mode 100644 index 00000000..2fe03d92 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/routing/api/internal/v1/UsersTest.kt @@ -0,0 +1,691 @@ +package dev.usbharu.hideout.routing.api.internal.v1 + +import com.auth0.jwt.interfaces.Claim +import com.auth0.jwt.interfaces.Payload +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.hideout.dto.UserResponse +import dev.usbharu.hideout.domain.model.hideout.entity.User +import dev.usbharu.hideout.domain.model.hideout.form.UserCreate +import dev.usbharu.hideout.plugins.TOKEN_AUTH +import dev.usbharu.hideout.plugins.configureSecurity +import dev.usbharu.hideout.plugins.configureSerialization +import dev.usbharu.hideout.service.IUserApiService +import dev.usbharu.hideout.service.impl.IUserService +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.config.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.Test +import org.mockito.kotlin.* +import utils.JsonObjectMapper +import java.time.Instant +import kotlin.test.assertEquals + +class UsersTest { + @Test + fun `users にGETするとユーザー一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val users = listOf( + UserResponse( + 12345, + "test1", + "example.com", + "test", + "", + "https://example.com/test", + Instant.now().toEpochMilli() + ), + UserResponse( + 12343, + "tes2", + "example.com", + "test", + "", + "https://example.com/tes2", + Instant.now().toEpochMilli() + ), + ) + val userService = mock { + onBlocking { findAll(anyOrNull(), anyOrNull()) } doReturn users + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userService) + } + } + } + client.get("/api/internal/v1/users").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(users, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users にPOSTすると新規ユーザー作成ができる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val userCreateDto = UserCreate("test", "XXXXXXX") + val userService = mock { + onBlocking { usernameAlreadyUse(any()) } doReturn false + onBlocking { createLocalUser(any()) } doReturn User( + id = 12345, + name = "test", + domain = "example.com", + screenName = "testUser", + description = "test user", + password = "XXXXXXX", + inbox = "https://example.com/inbox", + outbox = "https://example.com/outbox", + url = "https://example.com", + publicKey = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----", + privateKey = "-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----", + createdAt = Instant.now() + ) + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(userService, mock()) + } + } + } + + client.post("/api/internal/v1/users") { + contentType(ContentType.Application.Json) + setBody(JsonObjectMapper.objectMapper.writeValueAsString(userCreateDto)) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals( + "${Config.configData.url}/api/internal/v1/users/${userCreateDto.username}", + headers["Location"] + ) + } + } + + @Test + fun `users 既にユーザー名が使用されているときはBadRequestが帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val userCreateDto = UserCreate("test", "XXXXXXX") + val userService = mock { + onBlocking { usernameAlreadyUse(any()) } doReturn true + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(userService, mock()) + } + } + } + + client.post("/api/internal/v1/users") { + contentType(ContentType.Application.Json) + setBody(JsonObjectMapper.objectMapper.writeValueAsString(userCreateDto)) + }.apply { + assertEquals(HttpStatusCode.BadRequest, status) + } + } + + @Test + fun `users name にGETしたらユーザーを取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val userResponse = UserResponse( + 1234, + "test1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + val userApiService = mock { + onBlocking { findByAcct(any()) } doReturn userResponse + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/test1").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users id にGETしたらユーザーを取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val userResponse = UserResponse( + 1234, + "test1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + val userApiService = mock { + onBlocking { findById(any()) } doReturn userResponse + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/1234").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name@domain にGETしたらユーザーを取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val userResponse = UserResponse( + 1234, + "test1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + val userApiService = mock { + onBlocking { findByAcct(any()) } doReturn userResponse + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/test1").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users @name@domain にGETしたらユーザーを取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val userResponse = UserResponse( + 1234, + "test1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + val userApiService = mock { + onBlocking { findByAcct(any()) } doReturn userResponse + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/test1").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name followers にGETしたらフォロワー一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val followers = listOf( + UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ), UserResponse( + 1236, + "follower2", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + ) + val userApiService = mock { + onBlocking { findFollowersByAcct(any()) } doReturn followers + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/test1/followers").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name@domain followers にGETしたらフォロワー一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val followers = listOf( + UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ), UserResponse( + 1236, + "follower2", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + ) + val userApiService = mock { + onBlocking { findFollowersByAcct(any()) } doReturn followers + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/@test1@example.com/followers").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users id followers にGETしたらフォロワー一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val followers = listOf( + UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ), UserResponse( + 1236, + "follower2", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + ) + val userApiService = mock { + onBlocking { findFollowers(any()) } doReturn followers + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/1234/followers").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name followers に認証情報ありでGETしたらフォローできる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val claim = mock { + on { asLong() } doReturn 1234 + } + val payload = mock { + on { getClaim(eq("uid")) } doReturn claim + } + + val userApiService = mock { + onBlocking { findByAcct(any()) } doReturn UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + } + val userService = mock { + onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn true + } + application { + configureSerialization() + authentication { + bearer(TOKEN_AUTH) { + authenticate { + JWTPrincipal(payload) + } + } + } + routing { + route("/api/internal/v1") { + users(userService, userApiService) + } + } + } + + client.post("/api/internal/v1/users/test1/followers") { + header(HttpHeaders.Authorization, "Bearer test") + }.apply { + assertEquals(HttpStatusCode.OK, status) + } + } + + @Test + fun `users name followers に認証情報ありでGETしたらフォロー処理受付になることもある`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val claim = mock { + on { asLong() } doReturn 1234 + } + val payload = mock { + on { getClaim(eq("uid")) } doReturn claim + } + + val userApiService = mock { + onBlocking { findByAcct(any()) } doReturn UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + } + val userService = mock { + onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn false + } + application { + configureSerialization() + authentication { + bearer(TOKEN_AUTH) { + authenticate { + JWTPrincipal(payload) + } + } + } + routing { + route("/api/internal/v1") { + users(userService, userApiService) + } + } + } + + client.post("/api/internal/v1/users/test1/followers") { + header(HttpHeaders.Authorization, "Bearer test") + }.apply { + assertEquals(HttpStatusCode.Accepted, status) + } + } + + @Test + fun `users id followers に認証情報ありでGETしたらフォロー処理受付になることもある`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val claim = mock { + on { asLong() } doReturn 1234 + } + val payload = mock { + on { getClaim(eq("uid")) } doReturn claim + } + + val userApiService = mock { + onBlocking { findById(any()) } doReturn UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + } + val userService = mock { + onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn false + } + application { + configureSerialization() + authentication { + bearer(TOKEN_AUTH) { + authenticate { + JWTPrincipal(payload) + } + } + } + routing { + route("/api/internal/v1") { + users(userService, userApiService) + } + } + } + + client.post("/api/internal/v1/users/1235/followers") { + header(HttpHeaders.Authorization, "Bearer test") + }.apply { + assertEquals(HttpStatusCode.Accepted, status) + } + } + + @Test + fun `users name following にGETしたらフォロイー一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val followers = listOf( + UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ), UserResponse( + 1236, + "follower2", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + ) + val userApiService = mock { + onBlocking { findFollowingsByAcct(any()) } doReturn followers + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/test1/following").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users name@domain following にGETしたらフォロイー一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val followers = listOf( + UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ), UserResponse( + 1236, + "follower2", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + ) + val userApiService = mock { + onBlocking { findFollowingsByAcct(any()) } doReturn followers + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/test1@domain/following").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } + + @Test + fun `users id following にGETしたらフォロイー一覧を取得できる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + + val followers = listOf( + UserResponse( + 1235, + "follower1", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ), UserResponse( + 1236, + "follower2", + "example.com", + "test", + "test User", + "https://example.com/test", + Instant.now().toEpochMilli() + ) + ) + val userApiService = mock { + onBlocking { findFollowings(any()) } doReturn followers + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + routing { + route("/api/internal/v1") { + users(mock(), userApiService) + } + } + } + + client.get("/api/internal/v1/users/1234/following").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText())) + } + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImplTest.kt index 93dfec61..12e54893 100644 --- a/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImplTest.kt @@ -115,7 +115,7 @@ class ActivityPubFollowServiceImplTest { createdAt = Instant.now() ) ) - onBlocking { addFollowers(any(), any()) } doReturn Unit + onBlocking { addFollowers(any(), any()) } doReturn false } val activityPubFollowService = ActivityPubFollowServiceImpl( diff --git a/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImplTest.kt index af5274de..0372f88e 100644 --- a/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubNoteServiceImplTest.kt @@ -5,8 +5,9 @@ package dev.usbharu.hideout.service.activitypub import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.config.ConfigData -import dev.usbharu.hideout.domain.model.PostEntity +import dev.usbharu.hideout.domain.model.hideout.entity.Post import dev.usbharu.hideout.domain.model.hideout.entity.User +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility import dev.usbharu.hideout.domain.model.job.DeliverPostJob import dev.usbharu.hideout.service.impl.IUserService import dev.usbharu.hideout.service.job.JobQueueParentService @@ -72,13 +73,13 @@ class ActivityPubNoteServiceImplTest { } val jobQueueParentService = mock() val activityPubNoteService = ActivityPubNoteServiceImpl(mock(), jobQueueParentService, userService) - val postEntity = PostEntity( + val postEntity = Post( 1L, 1L, null, "test text", 1L, - 1, + Visibility.PUBLIC, "https://example.com" ) activityPubNoteService.createNote(postEntity) @@ -99,8 +100,14 @@ class ActivityPubNoteServiceImplTest { JobProps( data = mapOf( DeliverPostJob.actor.name to "https://follower.example.com", - DeliverPostJob.post.name to "{\"id\":1,\"userId\":1,\"inReplyToId\":null,\"text\":\"test text\"," + - "\"createdAt\":1,\"updatedAt\":1,\"url\":\"https://example.com\"}", + DeliverPostJob.post.name to """{ + "id": 1, + "userId": 1, + "text": "test text", + "createdAt": 132525324, + "visibility": 0, + "url": "https://example.com" +}""", DeliverPostJob.inbox.name to "https://follower.example.com/inbox" ), json = Json diff --git a/src/test/kotlin/dev/usbharu/hideout/service/impl/PostServiceTest.kt b/src/test/kotlin/dev/usbharu/hideout/service/impl/PostServiceTest.kt new file mode 100644 index 00000000..46e41fd2 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/service/impl/PostServiceTest.kt @@ -0,0 +1,162 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package dev.usbharu.hideout.service.impl + +import dev.usbharu.hideout.domain.model.hideout.entity.Post +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility +import dev.usbharu.hideout.repository.Posts +import dev.usbharu.hideout.repository.UsersFollowers +import dev.usbharu.hideout.service.TwitterSnowflakeIdGenerateService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.batchInsert +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import java.time.Instant +import kotlin.test.assertContentEquals + +class PostServiceTest { + + lateinit var db: Database + + @BeforeEach + fun setUp() { + db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver") + transaction(db) { + SchemaUtils.create(Posts) + connection.prepareStatement("SET REFERENTIAL_INTEGRITY FALSE", false).executeUpdate() + } + } + + @AfterEach + fun tearDown() { + transaction(db) { + SchemaUtils.drop(Posts) + } + } + + @Test + fun `findAll 公開投稿を取得できる`() = runTest { + val postService = PostService(mock(), mock(), mock()) + + suspend fun createPost(userId: Long, text: String, visibility: Visibility = Visibility.PUBLIC): Post { + return Post( + TwitterSnowflakeIdGenerateService.generateId(), + userId, + null, + text, + Instant.now().toEpochMilli(), + visibility, + "https://example.com" + ) + } + + val userA: Long = 1 + val userB: Long = 2 + + val posts = listOf( + createPost(userA, "hello"), + createPost(userA, "hello1"), + createPost(userA, "hello2"), + createPost(userA, "hello3"), + createPost(userA, "hello4"), + createPost(userA, "hello5"), + createPost(userA, "hello6"), + createPost(userB, "good bay ", Visibility.FOLLOWERS), + createPost(userB, "good bay1", Visibility.FOLLOWERS), + createPost(userB, "good bay2", Visibility.FOLLOWERS), + createPost(userB, "good bay3", Visibility.FOLLOWERS), + createPost(userB, "good bay4", Visibility.FOLLOWERS), + createPost(userB, "good bay5", Visibility.FOLLOWERS), + createPost(userB, "good bay6", Visibility.FOLLOWERS), + ) + + transaction { + Posts.batchInsert(posts) { + this[Posts.id] = it.id + this[Posts.userId] = it.userId + this[Posts.overview] = it.overview + this[Posts.text] = it.text + this[Posts.createdAt] = it.createdAt + this[Posts.visibility] = it.visibility.ordinal + this[Posts.url] = it.url + this[Posts.replyId] = it.replyId + this[Posts.repostId] = it.repostId + } + } + + val expect = posts.filter { it.visibility == Visibility.PUBLIC } + + val actual = postService.findAll() + assertContentEquals(expect, actual) + } + + @Test + fun `findAll フォロー限定投稿を見れる`() = runTest { + val postService = PostService(mock(), mock(), mock()) + + suspend fun createPost(userId: Long, text: String, visibility: Visibility = Visibility.PUBLIC): Post { + return Post( + TwitterSnowflakeIdGenerateService.generateId(), + userId, + null, + text, + Instant.now().toEpochMilli(), + visibility, + "https://example.com" + ) + } + + val userA: Long = 1 + val userB: Long = 2 + + val posts = listOf( + createPost(userA, "hello"), + createPost(userA, "hello1"), + createPost(userA, "hello2"), + createPost(userA, "hello3"), + createPost(userA, "hello4"), + createPost(userA, "hello5"), + createPost(userA, "hello6"), + createPost(userB, "good bay ", Visibility.FOLLOWERS), + createPost(userB, "good bay1", Visibility.FOLLOWERS), + createPost(userB, "good bay2", Visibility.FOLLOWERS), + createPost(userB, "good bay3", Visibility.FOLLOWERS), + createPost(userB, "good bay4", Visibility.FOLLOWERS), + createPost(userB, "good bay5", Visibility.FOLLOWERS), + createPost(userB, "good bay6", Visibility.FOLLOWERS), + ) + + transaction(db) { + SchemaUtils.create(UsersFollowers) + } + + transaction { + Posts.batchInsert(posts) { + this[Posts.id] = it.id + this[Posts.userId] = it.userId + this[Posts.overview] = it.overview + this[Posts.text] = it.text + this[Posts.createdAt] = it.createdAt + this[Posts.visibility] = it.visibility.ordinal + this[Posts.url] = it.url + this[Posts.replyId] = it.replyId + this[Posts.repostId] = it.repostId + } + UsersFollowers.insert { + it[id] = 100L + it[userId] = userB + it[followerId] = userA + } + } + + val actual = postService.findAll(userId = userA) + assertContentEquals(posts, actual) + } +}