Merge pull request #197 from usbharu/feature/mastodon-follow-api

Feature/mastodon follow api
This commit is contained in:
usbharu 2023-12-07 15:13:27 +09:00 committed by GitHub
commit d05e8cf68b
26 changed files with 1081 additions and 57 deletions

View File

@ -1,8 +1,10 @@
package mastodon.account package mastodon.account
import dev.usbharu.hideout.SpringApplication import dev.usbharu.hideout.SpringApplication
import dev.usbharu.hideout.core.infrastructure.exposedquery.FollowerQueryServiceImpl
import dev.usbharu.hideout.core.infrastructure.exposedquery.UserQueryServiceImpl import dev.usbharu.hideout.core.infrastructure.exposedquery.UserQueryServiceImpl
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -14,6 +16,7 @@ import org.springframework.http.MediaType
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.context.support.WithAnonymousUser import org.springframework.security.test.context.support.WithAnonymousUser
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.jdbc.Sql import org.springframework.test.context.jdbc.Sql
@ -29,11 +32,16 @@ import org.springframework.web.context.WebApplicationContext
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Transactional @Transactional
@Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
@Sql("/sql/test-user2.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
class AccountApiTest { class AccountApiTest {
@Autowired
private lateinit var followerQueryServiceImpl: FollowerQueryServiceImpl
@Autowired @Autowired
private lateinit var userQueryServiceImpl: UserQueryServiceImpl private lateinit var userQueryServiceImpl: UserQueryServiceImpl
@Autowired @Autowired
private lateinit var context: WebApplicationContext private lateinit var context: WebApplicationContext
@ -92,7 +100,7 @@ class AccountApiTest {
.asyncDispatch() .asyncDispatch()
.andExpect { status { isFound() } } .andExpect { status { isFound() } }
userQueryServiceImpl.findByNameAndDomain("api-test-user-1", "localhost") userQueryServiceImpl.findByNameAndDomain("api-test-user-1", "example.com")
} }
@Test @Test
@ -108,7 +116,7 @@ class AccountApiTest {
.asyncDispatch() .asyncDispatch()
.andExpect { status { isFound() } } .andExpect { status { isFound() } }
userQueryServiceImpl.findByNameAndDomain("api-test-user-2", "localhost") userQueryServiceImpl.findByNameAndDomain("api-test-user-2", "example.com")
} }
@Test @Test
@ -159,6 +167,120 @@ class AccountApiTest {
.andExpect { status { isForbidden() } } .andExpect { status { isForbidden() } }
} }
@Test
@WithAnonymousUser
fun `apiV1AccountsIdGet 匿名でアカウント情報を取得できる`() {
mockMvc
.get("/api/v1/accounts/1")
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsIdFollowPost write_follows権限でPOSTでフォローできる`() {
mockMvc
.post("/api/v1/accounts/2/follow") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:follows")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsIdFollowPost write権限でPOSTでフォローできる`() {
mockMvc
.post("/api/v1/accounts/2/follow") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsIdFollowPost read権限でだと403`() {
mockMvc
.post("/api/v1/accounts/2/follow") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")))
}
.andExpect { status { isForbidden() } }
}
@Test
@WithAnonymousUser
fun `apiV1AAccountsIdFollowPost 匿名だと401`() {
mockMvc
.post("/api/v1/accounts/2/follow") {
contentType = MediaType.APPLICATION_JSON
with(csrf())
}
.andExpect { status { isUnauthorized() } }
}
@Test
@WithAnonymousUser
fun `apiV1AAccountsIdFollowPost 匿名の場合通常csrfトークンは持ってないので403`() {
mockMvc
.post("/api/v1/accounts/2/follow") {
contentType = MediaType.APPLICATION_JSON
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV1AccountsRelationshipsGet 匿名だと401`() {
mockMvc
.get("/api/v1/accounts/relationships")
.andExpect { status { isUnauthorized() } }
}
@Test
fun `apiV1AccountsRelationshipsGet read_follows権限を持っていたら取得できる`() {
mockMvc
.get("/api/v1/accounts/relationships") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:follows")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsRelationshipsGet read権限を持っていたら取得できる`() {
mockMvc
.get("/api/v1/accounts/relationships") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsRelationshipsGet write権限だと403`() {
mockMvc
.get("/api/v1/accounts/relationships") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.andExpect { status { isForbidden() } }
}
@Test
@Sql("/sql/accounts/apiV1AccountsIdFollowPost フォローできる.sql")
fun `apiV1AccountsIdFollowPost フォローできる`() = runTest {
mockMvc
.post("/api/v1/accounts/3733363/follow") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "37335363") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
val alreadyFollow = followerQueryServiceImpl.alreadyFollow(3733363, 37335363)
assertThat(alreadyFollow).isTrue()
}
companion object { companion object {
@JvmStatic @JvmStatic
@AfterAll @AfterAll

View File

@ -1,5 +1,5 @@
hideout: hideout:
url: "https://localhost:8080" url: "https://example.com"
use-mongodb: true use-mongodb: true
security: security:
jwt: jwt:

View File

@ -0,0 +1,18 @@
insert into "USERS" (id, name, domain, screen_name, description, password, inbox, outbox, url, public_key, private_key,
created_at, key_id, following, followers, instance)
VALUES (3733363, 'follow-test-user-1', 'example.com', 'follow-test-user-1-name', '',
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
'https://example.com/users/follow-test-user-1/inbox',
'https://example.com/users/follow-test-user-1/outbox', 'https://example.com/users/follow-test-user-1',
'-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----',
'-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----', 12345678,
'https://example.com/users/follow-test-user-1#pubkey', 'https://example.com/users/follow-test-user-1/following',
'https://example.com/users/follow-test-user-1/followers', null),
(37335363, 'follow-test-user-2', 'example.com', 'follow-test-user-2-name', '',
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
'https://example.com/users/follow-test-user-2/inbox',
'https://example.com/users/follow-test-user-2/outbox', 'https://example.com/users/follow-test-user-2',
'-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----',
'-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----', 12345678,
'https://example.com/users/follow-test-user-2#pubkey', 'https://example.com/users/follow-test-user-2/following',
'https://example.com/users/follow-test-user-2/followers', null);

View File

@ -0,0 +1,10 @@
insert into "USERS" (ID, NAME, DOMAIN, SCREEN_NAME, DESCRIPTION, PASSWORD, INBOX, OUTBOX, URL, PUBLIC_KEY, PRIVATE_KEY,
CREATED_AT, KEY_ID, FOLLOWING, FOLLOWERS, INSTANCE)
VALUES (2, 'test-user2', 'example.com', 'Im test user.', 'THis account is test user.',
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
'https://example.com/users/test-user2/inbox',
'https://example.com/users/test-user2/outbox', 'https://example.com/users/test-user2',
'-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----',
'-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----', 12345678,
'https://example.com/users/test-user2#pubkey', 'https://example.com/users/test-user2/following',
'https://example.com/users/test-user2s/followers', null);

View File

@ -1,9 +1,12 @@
package dev.usbharu.hideout.activitypub.domain.model package dev.usbharu.hideout.activitypub.domain.model
import com.fasterxml.jackson.annotation.JsonSetter
import com.fasterxml.jackson.annotation.Nulls
import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.Object
open class Document( open class Document(
type: List<String> = emptyList(), type: List<String> = emptyList(),
@JsonSetter(nulls = Nulls.AS_EMPTY)
override val name: String = "", override val name: String = "",
val mediaType: String, val mediaType: String,
val url: String val url: String

View File

@ -4,12 +4,11 @@ import dev.usbharu.hideout.activitypub.domain.model.objects.Object
open class Image( open class Image(
type: List<String> = emptyList(), type: List<String> = emptyList(),
val mediaType: String, val mediaType: String? = null,
val url: String val url: String
) : Object( ) : Object(
add(type, "Image") add(type, "Image")
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
@ -25,10 +24,16 @@ open class Image(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = super.hashCode() var result = super.hashCode()
result = 31 * result + mediaType.hashCode() result = 31 * result + (mediaType?.hashCode() ?: 0)
result = 31 * result + url.hashCode() result = 31 * result + url.hashCode()
return result return result
} }
override fun toString(): String = "Image(mediaType=$mediaType, url=$url) ${super.toString()}" override fun toString(): String {
return "Image(" +
"mediaType=$mediaType, " +
"url='$url'" +
")" +
" ${super.toString()}"
}
} }

View File

@ -84,7 +84,7 @@ class APRequestServiceImpl(
headers { headers {
appendAll(headers) appendAll(headers)
append("Signature", sign.signatureHeader) append("Signature", sign.signatureHeader)
remove("Host") // remove("Host")
} }
} }
contentType(Activity) contentType(Activity)
@ -173,7 +173,7 @@ class APRequestServiceImpl(
append("Accept", Activity) append("Accept", Activity)
append("Date", date) append("Date", date)
append("Host", u.host) append("Host", u.host)
append("Digest", "sha-256=$digest") append("Digest", "SHA-256=$digest")
} }
val sign = httpSignatureSigner.sign( val sign = httpSignatureSigner.sign(
@ -193,7 +193,7 @@ class APRequestServiceImpl(
headers { headers {
appendAll(headers) appendAll(headers)
append("Signature", sign.signatureHeader) append("Signature", sign.signatureHeader)
remove("Host") // remove("Host")
} }
setBody(requestBody) setBody(requestBody)
contentType(Activity) contentType(Activity)

View File

@ -99,22 +99,20 @@ class InboxJobProcessor(
val verify = signature?.let { verifyHttpSignature(httpRequest, it, transaction) } ?: false val verify = signature?.let { verifyHttpSignature(httpRequest, it, transaction) } ?: false
transaction.transaction { logger.debug("Is verifying success? {}", verify)
logger.debug("Is verifying success? {}", verify)
val activityPubProcessor = val activityPubProcessor =
activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor<Object>? activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor<Object>?
if (activityPubProcessor == null) { if (activityPubProcessor == null) {
logger.warn("ActivityType {} is not support.", param.type) logger.warn("ActivityType {} is not support.", param.type)
throw IllegalStateException("ActivityPubProcessor not found.") throw IllegalStateException("ActivityPubProcessor not found.")
}
val value = objectMapper.treeToValue(jsonNode, activityPubProcessor.type())
activityPubProcessor.process(ActivityPubProcessContext(value, jsonNode, httpRequest, signature, verify))
logger.info("SUCCESS Process inbox. type: {}", param.type)
} }
val value = objectMapper.treeToValue(jsonNode, activityPubProcessor.type())
activityPubProcessor.process(ActivityPubProcessContext(value, jsonNode, httpRequest, signature, verify))
logger.info("SUCCESS Process inbox. type: {}", param.type)
} }
override fun job(): InboxJob = InboxJob override fun job(): InboxJob = InboxJob

View File

@ -58,8 +58,8 @@ class APUserServiceImpl(
url = userUrl, url = userUrl,
icon = Image( icon = Image(
type = emptyList(), type = emptyList(),
mediaType = "image/png", mediaType = "image/jpeg",
url = "$userUrl/icon.png" url = "$userUrl/icon.jpg"
), ),
publicKey = Key( publicKey = Key(
id = userEntity.keyId, id = userEntity.keyId,
@ -124,8 +124,8 @@ class APUserServiceImpl(
url = id, url = id,
icon = Image( icon = Image(
type = emptyList(), type = emptyList(),
mediaType = "image/png", mediaType = "image/jpeg",
url = "$id/icon.png" url = "$id/icon.jpg"
), ),
publicKey = Key( publicKey = Key(
id = userEntity.keyId, id = userEntity.keyId,

View File

@ -180,17 +180,23 @@ class SecurityConfig {
authorize(POST, "/inbox", permitAll) authorize(POST, "/inbox", permitAll)
authorize(POST, "/users/*/inbox", permitAll) authorize(POST, "/users/*/inbox", permitAll)
authorize(GET, "/users/*", permitAll)
authorize(GET, "/users/*/posts/*", permitAll)
authorize(POST, "/api/v1/apps", permitAll) authorize(POST, "/api/v1/apps", permitAll)
authorize(GET, "/api/v1/instance/**", permitAll) authorize(GET, "/api/v1/instance/**", permitAll)
authorize(POST, "/api/v1/accounts", permitAll) authorize(POST, "/api/v1/accounts", permitAll)
authorize("/auth/sign_up", hasRole("ANONYMOUS")) authorize("/auth/sign_up", hasRole("ANONYMOUS"))
authorize(GET, "/files", permitAll) authorize(GET, "/files/*", permitAll)
authorize(GET, "/users/*/icon.jpg", permitAll) authorize(GET, "/users/*/icon.jpg", permitAll)
authorize(GET, "/users/*/header.jpg", permitAll) authorize(GET, "/users/*/header.jpg", permitAll)
authorize(GET, "/api/v1/accounts/verify_credentials", hasAnyScope("read", "read:accounts")) authorize(GET, "/api/v1/accounts/verify_credentials", hasAnyScope("read", "read:accounts"))
authorize(GET, "/api/v1/accounts/relationships", hasAnyScope("read", "read:follows"))
authorize(GET, "/api/v1/accounts/*", permitAll)
authorize(GET, "/api/v1/accounts/*/statuses", permitAll)
authorize(POST, "/api/v1/accounts/*/follow", hasAnyScope("write", "write:follows"))
authorize(POST, "/api/v1/media", hasAnyScope("write", "write:media")) authorize(POST, "/api/v1/media", hasAnyScope("write", "write:media"))
authorize(POST, "/api/v1/statuses", hasAnyScope("write", "write:statuses")) authorize(POST, "/api/v1/statuses", hasAnyScope("write", "write:statuses"))

View File

@ -2,19 +2,24 @@ package dev.usbharu.hideout.application.infrastructure.exposed
import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.application.external.Transaction
import kotlinx.coroutines.slf4j.MDCContext import kotlinx.coroutines.slf4j.MDCContext
import org.jetbrains.exposed.sql.StdOutSqlLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.sql.Connection
@Service @Service
class ExposedTransaction : Transaction { class ExposedTransaction : Transaction {
override suspend fun <T> transaction(block: suspend () -> T): T { override suspend fun <T> transaction(block: suspend () -> T): T {
return newSuspendedTransaction(MDCContext()) { return newSuspendedTransaction(MDCContext(), transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) {
addLogger(StdOutSqlLogger)
block() block()
} }
} }
override suspend fun <T> transaction(transactionLevel: Int, block: suspend () -> T): T { override suspend fun <T> transaction(transactionLevel: Int, block: suspend () -> T): T {
return newSuspendedTransaction(MDCContext(), transactionIsolation = transactionLevel) { return newSuspendedTransaction(MDCContext(), transactionIsolation = transactionLevel) {
addLogger(StdOutSqlLogger)
block() block()
} }
} }

View File

@ -8,6 +8,7 @@ import kjob.core.dsl.JobRegisterContext
import kjob.core.dsl.KJobFunctions import kjob.core.dsl.KJobFunctions
import kjob.core.kjob import kjob.core.kjob
import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.slf4j.MDC
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -35,8 +36,13 @@ class KJobJobQueueWorkerService(private val jobQueueProcessorList: List<JobProce
for (jobProcessor in jobQueueProcessorList) { for (jobProcessor in jobQueueProcessorList) {
kjob.register(jobProcessor.job()) { kjob.register(jobProcessor.job()) {
execute { execute {
val param = it.convertUnsafe(props) try {
jobProcessor.process(param) MDC.put("x-job-id", this.jobId)
val param = it.convertUnsafe(props)
jobProcessor.process(param)
} finally {
MDC.remove("x-job-id")
}
} }
} }
} }

View File

@ -8,9 +8,7 @@ import jakarta.servlet.http.HttpServletRequest
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter
import java.net.URL import java.net.URL
class HttpSignatureFilter( class HttpSignatureFilter(private val httpSignatureHeaderParser: SignatureHeaderParser) :
private val httpSignatureHeaderParser: SignatureHeaderParser
) :
AbstractPreAuthenticatedProcessingFilter() { AbstractPreAuthenticatedProcessingFilter() {
override fun getPreAuthenticatedPrincipal(request: HttpServletRequest?): Any? { override fun getPreAuthenticatedPrincipal(request: HttpServletRequest?): Any? {
val headersList = request?.headerNames?.toList().orEmpty() val headersList = request?.headerNames?.toList().orEmpty()

View File

@ -114,10 +114,13 @@ class UserServiceImpl(
} }
override suspend fun follow(id: Long, followerId: Long) { override suspend fun follow(id: Long, followerId: Long) {
logger.debug("START Follow id: {} → target: {}", followerId, id)
followerQueryService.appendFollower(id, followerId) followerQueryService.appendFollower(id, followerId)
if (userRepository.findFollowRequestsById(id, followerId)) { if (userRepository.findFollowRequestsById(id, followerId)) {
logger.debug("Follow request is accepted! ")
userRepository.deleteFollowRequest(id, followerId) userRepository.deleteFollowRequest(id, followerId)
} }
logger.debug("SUCCESS Follow id: {} → target: {}", followerId, id)
} }
override suspend fun unfollow(id: Long, followerId: Long): Boolean { override suspend fun unfollow(id: Long, followerId: Long): Boolean {

View File

@ -4,9 +4,11 @@ import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments
import dev.usbharu.hideout.core.infrastructure.exposedrepository.* import dev.usbharu.hideout.core.infrastructure.exposedrepository.*
import dev.usbharu.hideout.domain.mastodon.model.generated.Account import dev.usbharu.hideout.domain.mastodon.model.generated.Account
import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.domain.mastodon.model.generated.Status.Visibility.*
import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery
import dev.usbharu.hideout.mastodon.query.StatusQueryService import dev.usbharu.hideout.mastodon.query.StatusQueryService
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.Instant import java.time.Instant
@ -40,6 +42,62 @@ class StatusQueryServiceImpl : StatusQueryService {
} }
} }
override suspend fun accountsStatus(
accountId: Long,
maxId: Long?,
sinceId: Long?,
minId: Long?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?,
includeFollowers: Boolean
): List<Status> {
val query = Posts
.leftJoin(PostsMedia)
.leftJoin(Users)
.leftJoin(Media)
.select { Posts.userId eq accountId }.limit(20)
if (maxId != null) {
query.andWhere { Posts.id eq maxId }
}
if (sinceId != null) {
query.andWhere { Posts.id eq sinceId }
}
if (minId != null) {
query.andWhere { Posts.id eq minId }
}
if (onlyMedia) {
query.andWhere { PostsMedia.mediaId.isNotNull() }
}
if (excludeReplies) {
query.andWhere { Posts.replyId.isNotNull() }
}
if (excludeReblogs) {
query.andWhere { Posts.repostId.isNotNull() }
}
if (includeFollowers) {
query.andWhere { Posts.visibility inList listOf(public.ordinal, unlisted.ordinal, private.ordinal) }
} else {
query.andWhere { Posts.visibility inList listOf(public.ordinal, unlisted.ordinal) }
}
val pairs = query.groupBy { it[Posts.id] }
.map { it.value }
.map {
toStatus(it.first()).copy(
mediaAttachments = it.mapNotNull { resultRow ->
resultRow.toMediaOrNull()?.toMediaAttachments()
}
) to it.first()[Posts.repostId]
}
return resolveReplyAndRepost(pairs)
}
private fun resolveReplyAndRepost(pairs: List<Pair<Status, Long?>>): List<Status> { private fun resolveReplyAndRepost(pairs: List<Pair<Status, Long?>>): List<Status> {
val statuses = pairs.map { it.first } val statuses = pairs.map { it.first }
return pairs return pairs
@ -111,11 +169,11 @@ private fun toStatus(it: ResultRow) = Status(
), ),
content = it[Posts.text], content = it[Posts.text],
visibility = when (it[Posts.visibility]) { visibility = when (it[Posts.visibility]) {
0 -> Status.Visibility.public 0 -> public
1 -> Status.Visibility.unlisted 1 -> unlisted
2 -> Status.Visibility.private 2 -> private
3 -> Status.Visibility.direct 3 -> direct
else -> Status.Visibility.public else -> public
}, },
sensitive = it[Posts.sensitive], sensitive = it[Posts.sensitive],
spoilerText = it[Posts.overview].orEmpty(), spoilerText = it[Posts.overview].orEmpty(),

View File

@ -3,8 +3,11 @@ package dev.usbharu.hideout.mastodon.interfaces.api.account
import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.controller.mastodon.generated.AccountApi import dev.usbharu.hideout.controller.mastodon.generated.AccountApi
import dev.usbharu.hideout.core.service.user.UserCreateDto import dev.usbharu.hideout.core.service.user.UserCreateDto
import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccount import dev.usbharu.hideout.domain.mastodon.model.generated.*
import dev.usbharu.hideout.mastodon.service.account.AccountApiService import dev.usbharu.hideout.mastodon.service.account.AccountApiService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.runBlocking
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@ -18,6 +21,19 @@ class MastodonAccountApiController(
private val accountApiService: AccountApiService, private val accountApiService: AccountApiService,
private val transaction: Transaction private val transaction: Transaction
) : AccountApi { ) : AccountApi {
override suspend fun apiV1AccountsIdFollowPost(
id: String,
followRequestBody: FollowRequestBody?
): ResponseEntity<Relationship> {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
return ResponseEntity.ok(accountApiService.follow(principal.getClaim<String>("uid").toLong(), id.toLong()))
}
override suspend fun apiV1AccountsIdGet(id: String): ResponseEntity<Account> =
ResponseEntity.ok(accountApiService.account(id.toLong()))
override suspend fun apiV1AccountsVerifyCredentialsGet(): ResponseEntity<CredentialAccount> { override suspend fun apiV1AccountsVerifyCredentialsGet(): ResponseEntity<CredentialAccount> {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
@ -42,4 +58,49 @@ class MastodonAccountApiController(
httpHeaders.location = URI("/users/$username") httpHeaders.location = URI("/users/$username")
return ResponseEntity(Unit, httpHeaders, HttpStatus.FOUND) return ResponseEntity(Unit, httpHeaders, HttpStatus.FOUND)
} }
override fun apiV1AccountsIdStatusesGet(
id: String,
maxId: String?,
sinceId: String?,
minId: String?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?
): ResponseEntity<Flow<Status>> = runBlocking {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
val userid = principal.getClaim<String>("uid").toLong()
val statusFlow = accountApiService.accountsStatuses(
id.toLong(),
maxId?.toLongOrNull(),
sinceId?.toLongOrNull(),
minId?.toLongOrNull(),
limit,
onlyMedia,
excludeReplies,
excludeReblogs,
pinned,
tagged,
userid
).asFlow()
ResponseEntity.ok(statusFlow)
}
override fun apiV1AccountsRelationshipsGet(
id: List<String>?,
withSuspended: Boolean
): ResponseEntity<Flow<Relationship>> = runBlocking {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
val userid = principal.getClaim<String>("uid").toLong()
ResponseEntity.ok(
accountApiService.relationships(userid, id.orEmpty().mapNotNull { it.toLongOrNull() }, withSuspended)
.asFlow()
)
}
} }

View File

@ -6,4 +6,34 @@ import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery
interface StatusQueryService { interface StatusQueryService {
suspend fun findByPostIds(ids: List<Long>): List<Status> suspend fun findByPostIds(ids: List<Long>): List<Status>
suspend fun findByPostIdsWithMediaIds(statusQueries: List<StatusQuery>): List<Status> suspend fun findByPostIdsWithMediaIds(statusQueries: List<StatusQuery>): List<Status>
/**
* アカウントの投稿一覧を取得します
*
* @param accountId 対象アカウントのid
* @param maxId 投稿の最大id
* @param sinceId 投稿の最小id
* @param minId 不明
* @param limit 投稿の最大件数
* @param onlyMedia メディア付き投稿のみ
* @param excludeReplies 返信を除外
* @param excludeReblogs リブログを除外
* @param pinned ピン止め投稿のみ
* @param tagged タグ付き?
* @param includeFollowers フォロワー限定投稿を含める
*/
@Suppress("LongParameterList")
suspend fun accountsStatus(
accountId: Long,
maxId: Long? = null,
sinceId: Long? = null,
minId: Long? = null,
limit: Int,
onlyMedia: Boolean = false,
excludeReplies: Boolean = false,
excludeReblogs: Boolean = false,
pinned: Boolean = false,
tagged: String? = null,
includeFollowers: Boolean = false
): List<Status>
} }

View File

@ -1,27 +1,87 @@
package dev.usbharu.hideout.mastodon.service.account package dev.usbharu.hideout.mastodon.service.account
import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.domain.model.user.UserRepository
import dev.usbharu.hideout.core.query.FollowerQueryService
import dev.usbharu.hideout.core.service.user.UserCreateDto import dev.usbharu.hideout.core.service.user.UserCreateDto
import dev.usbharu.hideout.core.service.user.UserService import dev.usbharu.hideout.core.service.user.UserService
import dev.usbharu.hideout.domain.mastodon.model.generated.Account import dev.usbharu.hideout.domain.mastodon.model.generated.*
import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccount import dev.usbharu.hideout.mastodon.query.StatusQueryService
import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccountSource import org.slf4j.LoggerFactory
import dev.usbharu.hideout.domain.mastodon.model.generated.Role
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import kotlin.math.min
@Service @Service
interface AccountApiService { interface AccountApiService {
suspend fun accountsStatuses(
userid: Long,
maxId: Long?,
sinceId: Long?,
minId: Long?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?,
loginUser: Long?
): List<Status>
suspend fun verifyCredentials(userid: Long): CredentialAccount suspend fun verifyCredentials(userid: Long): CredentialAccount
suspend fun registerAccount(userCreateDto: UserCreateDto): Unit suspend fun registerAccount(userCreateDto: UserCreateDto): Unit
suspend fun follow(userid: Long, followeeId: Long): Relationship
suspend fun account(id: Long): Account
suspend fun relationships(userid: Long, id: List<Long>, withSuspended: Boolean): List<Relationship>
} }
@Service @Service
class AccountApiServiceImpl( class AccountApiServiceImpl(
private val accountService: AccountService, private val accountService: AccountService,
private val transaction: Transaction, private val transaction: Transaction,
private val userService: UserService private val userService: UserService,
private val followerQueryService: FollowerQueryService,
private val userRepository: UserRepository,
private val statusQueryService: StatusQueryService
) : ) :
AccountApiService { AccountApiService {
override suspend fun accountsStatuses(
userid: Long,
maxId: Long?,
sinceId: Long?,
minId: Long?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?,
loginUser: Long?
): List<Status> {
val canViewFollowers = if (loginUser == null) {
false
} else {
transaction.transaction {
followerQueryService.alreadyFollow(userid, loginUser)
}
}
return transaction.transaction {
statusQueryService.accountsStatus(
userid,
maxId,
sinceId,
minId,
limit,
onlyMedia,
excludeReplies,
excludeReblogs,
pinned,
tagged,
canViewFollowers
)
}
}
override suspend fun verifyCredentials(userid: Long): CredentialAccount = transaction.transaction { override suspend fun verifyCredentials(userid: Long): CredentialAccount = transaction.transaction {
val account = accountService.findById(userid) val account = accountService.findById(userid)
from(account) from(account)
@ -31,6 +91,75 @@ class AccountApiServiceImpl(
userService.createLocalUser(UserCreateDto(userCreateDto.name, userCreateDto.name, "", userCreateDto.password)) userService.createLocalUser(UserCreateDto(userCreateDto.name, userCreateDto.name, "", userCreateDto.password))
} }
override suspend fun follow(userid: Long, followeeId: Long): Relationship = transaction.transaction {
val alreadyFollow = followerQueryService.alreadyFollow(followeeId, userid)
val followRequest = if (alreadyFollow) {
true
} else {
userService.followRequest(followeeId, userid)
}
val alreadyFollow1 = followerQueryService.alreadyFollow(userid, followeeId)
val followRequestsById = userRepository.findFollowRequestsById(followeeId, userid)
return@transaction Relationship(
followeeId.toString(),
followRequest,
true,
false,
alreadyFollow1,
false,
false,
false,
false,
followRequestsById,
false,
false,
""
)
}
override suspend fun account(id: Long): Account = transaction.transaction {
return@transaction accountService.findById(id)
}
override suspend fun relationships(userid: Long, id: List<Long>, withSuspended: Boolean): List<Relationship> =
transaction.transaction {
if (id.isEmpty()) {
return@transaction emptyList()
}
logger.warn("id is too long! ({}) truncate to 20", id.size)
val subList = id.subList(0, min(id.size, 20))
return@transaction subList.map {
val alreadyFollow = followerQueryService.alreadyFollow(userid, it)
val followed = followerQueryService.alreadyFollow(it, userid)
val requested = userRepository.findFollowRequestsById(it, userid)
Relationship(
id = it.toString(),
following = alreadyFollow,
showingReblogs = true,
notifying = false,
followedBy = followed,
blocking = false,
blockedBy = false,
muting = false,
mutingNotifications = false,
requested = requested,
domainBlocking = false,
endorsed = false,
note = ""
)
}
}
private fun from(account: Account): CredentialAccount { private fun from(account: Account): CredentialAccount {
return CredentialAccount( return CredentialAccount(
id = account.id, id = account.id,
@ -68,4 +197,8 @@ class AccountApiServiceImpl(
role = Role(0, "Admin", "", 32) role = Role(0, "Admin", "", 32)
) )
} }
companion object {
private val logger = LoggerFactory.getLogger(AccountApiServiceImpl::class.java)
}
} }

View File

@ -1,5 +1,6 @@
package dev.usbharu.hideout.mastodon.service.account package dev.usbharu.hideout.mastodon.service.account
import dev.usbharu.hideout.application.config.ApplicationConfig
import dev.usbharu.hideout.core.query.UserQueryService import dev.usbharu.hideout.core.query.UserQueryService
import dev.usbharu.hideout.domain.mastodon.model.generated.Account import dev.usbharu.hideout.domain.mastodon.model.generated.Account
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -10,9 +11,14 @@ interface AccountService {
} }
@Service @Service
class AccountServiceImpl(private val userQueryService: UserQueryService) : AccountService { class AccountServiceImpl(
private val userQueryService: UserQueryService,
private val applicationConfig: ApplicationConfig
) : AccountService {
override suspend fun findById(id: Long): Account { override suspend fun findById(id: Long): Account {
val findById = userQueryService.findById(id) val findById = userQueryService.findById(id)
val userUrl = applicationConfig.url.toString() + "/users/" + findById.id.toString()
return Account( return Account(
id = findById.id.toString(), id = findById.id.toString(),
username = findById.name, username = findById.name,
@ -20,10 +26,10 @@ class AccountServiceImpl(private val userQueryService: UserQueryService) : Accou
url = findById.url, url = findById.url,
displayName = findById.screenName, displayName = findById.screenName,
note = findById.description, note = findById.description,
avatar = findById.url + "/icon.jpg", avatar = "$userUrl/icon.jpg",
avatarStatic = findById.url + "/icon.jpg", avatarStatic = "$userUrl/icon.jpg",
header = findById.url + "/header.jpg", header = "$userUrl/header.jpg",
headerStatic = findById.url + "/header.jpg", headerStatic = "$userUrl/header.jpg",
locked = false, locked = false,
fields = emptyList(), fields = emptyList(),
emojis = emptyList(), emojis = emptyList(),

View File

@ -13,7 +13,6 @@ import org.springframework.security.oauth2.server.authorization.settings.ClientS
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings import org.springframework.security.oauth2.server.authorization.settings.TokenSettings
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Duration import java.time.Duration
import java.time.Instant
import java.util.* import java.util.*
@Service @Service
@ -46,7 +45,7 @@ class AppApiServiceImpl(
.tokenSettings( .tokenSettings(
TokenSettings.builder() TokenSettings.builder()
.accessTokenTimeToLive( .accessTokenTimeToLive(
Duration.ofSeconds((Instant.MAX.epochSecond - Instant.now().epochSecond - 10000) / 1000) Duration.ofSeconds(31536000000)
) )
.build() .build()
) )
@ -60,7 +59,8 @@ class AppApiServiceImpl(
"invalid-vapid-key", "invalid-vapid-key",
appsRequest.website, appsRequest.website,
id, id,
clientSecret clientSecret,
appsRequest.redirectUris
) )
} }
} }

View File

@ -36,7 +36,7 @@ spring:
max-request-size: 40MB max-request-size: 40MB
h2: h2:
console: console:
enabled: false enabled: true
server: server:
tomcat: tomcat:
basedir: tomcat basedir: tomcat

View File

@ -31,7 +31,7 @@ create table if not exists users
"following" varchar(1000) null, "following" varchar(1000) null,
followers varchar(1000) null, followers varchar(1000) null,
"instance" bigint null, "instance" bigint null,
unique (name, domain), unique ("name", "domain"),
constraint fk_users_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict constraint fk_users_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict
); );
create table if not exists follow_requests create table if not exists follow_requests

View File

@ -4,7 +4,7 @@
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{x-request-id}] %logger{36} - %msg%n</pattern> <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{x-request-id}] %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<root level="INFO"> <root level="DEBUG">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
</root> </root>
<logger name="org.eclipse.jetty" level="INFO"/> <logger name="org.eclipse.jetty" level="INFO"/>
@ -12,7 +12,7 @@
<logger name="kjob.core.internal.scheduler.JobServiceImpl" level="INFO"/> <logger name="kjob.core.internal.scheduler.JobServiceImpl" level="INFO"/>
<logger name="Exposed" level="INFO"/> <logger name="Exposed" level="INFO"/>
<logger name="io.ktor.server.plugins.contentnegotiation" level="INFO"/> <logger name="io.ktor.server.plugins.contentnegotiation" level="INFO"/>
<logger name="org.springframework.web.filter.CommonsRequestLoggingFilter" level="INFO"/> <logger name="org.springframework.web.filter.CommonsRequestLoggingFilter" level="DEBUG"/>
<logger name="org.mongodb.driver.protocol.command" level="INFO"/> <logger name="org.mongodb.driver.protocol.command" level="INFO"/>
<logger name="dev.usbharu" level="TRACE"/> <logger name="dev.usbharu" level="TRACE"/>
</configuration> </configuration>

View File

@ -206,6 +206,161 @@ paths:
200: 200:
description: 成功 description: 成功
/api/v1/accounts/relationships:
get:
tags:
- account
security:
- OAuth2:
- "read:follows"
parameters:
- in: query
name: id[]
required: false
schema:
type: array
items:
type: string
- in: query
name: with_suspended
required: false
schema:
type: boolean
default: false
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Relationship"
/api/v1/accounts/{id}:
get:
tags:
- account
security:
- { }
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Account"
/api/v1/accounts/{id}/follow:
post:
tags:
- account
security:
- OAuth2:
- "write:follows"
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/FollowRequestBody"
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/FollowRequestBody"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Relationship"
/api/v1/accounts/{id}/statuses:
get:
tags:
- account
security:
- OAuth2:
- "read:statuses"
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: query
name: max_id
required: false
schema:
type: string
- in: query
name: since_id
required: false
schema:
type: string
- in: query
name: min_id
required: false
schema:
type: string
- in: query
name: limit
required: false
schema:
type: integer
default: 20
- in: query
name: only_media
required: false
schema:
type: boolean
default: false
- in: query
name: exclude_replies
required: false
schema:
type: boolean
default: false
- in: query
name: exclude_reblogs
required: false
schema:
type: boolean
default: false
- in: query
name: pinned
required: false
schema:
type: boolean
default: false
- in: query
required: false
name: tagged
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Status"
/api/v1/timelines/public: /api/v1/timelines/public:
get: get:
tags: tags:
@ -1314,6 +1469,8 @@ components:
type: string type: string
client_secret: client_secret:
type: string type: string
redirect_uri:
type: string
required: required:
- name - name
- vapid_key - vapid_key
@ -1333,6 +1490,65 @@ components:
- client_name - client_name
- redirect_uris - redirect_uris
Relationship:
type: object
properties:
id:
type: string
following:
type: boolean
showing_reblogs:
type: boolean
notifying:
type: boolean
followed_by:
type: boolean
blocking:
type: boolean
blocked_by:
type: boolean
muting:
type: boolean
muting_notifications:
type: boolean
requested:
type: boolean
domain_blocking:
type: boolean
endorsed:
type: boolean
note:
type: string
required:
- id
- following
- showing_reblogs
- notifying
- followed_by
- blocking
- blocked_by
- muting
- muting_notifications
- requested
- domain_blocking
- endorsed
- note
FollowRequestBody:
type: object
properties:
reblogs:
type: boolean
default: true
notify:
type: boolean
default: false
languages:
type: array
items:
type: string
securitySchemes: securitySchemes:
OAuth2: OAuth2:
type: oauth2 type: oauth2

View File

@ -125,4 +125,20 @@ class MastodonAccountApiControllerTest {
.andExpect { header { string("location", "/users/hoge") } } .andExpect { header { string("location", "/users/hoge") } }
.andExpect { status { isFound() } } .andExpect { status { isFound() } }
} }
@Test
fun `apiV1AccountsIdFollowPost フォロー成功時は200が返ってくる`() {
val createEmptyContext = SecurityContextHolder.createEmptyContext()
createEmptyContext.authentication = JwtAuthenticationToken(
Jwt.withTokenValue("a").header("alg", "RS236").claim("uid", "1234").build()
)
SecurityContextHolder.setContext(createEmptyContext)
mockMvc
.post("/api/v1/accounts/1/follow") {
contentType = MediaType.APPLICATION_JSON
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
} }

View File

@ -0,0 +1,330 @@
package dev.usbharu.hideout.mastodon.service.account
import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.domain.model.user.UserRepository
import dev.usbharu.hideout.core.query.FollowerQueryService
import dev.usbharu.hideout.core.service.user.UserService
import dev.usbharu.hideout.domain.mastodon.model.generated.Account
import dev.usbharu.hideout.domain.mastodon.model.generated.Relationship
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.mastodon.query.StatusQueryService
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Spy
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.*
import utils.TestTransaction
@ExtendWith(MockitoExtension::class)
class AccountApiServiceImplTest {
@Mock
private lateinit var accountService: AccountService
@Mock
private lateinit var userService: UserService
@Mock
private lateinit var userRepository: UserRepository
@Mock
private lateinit var followerQueryService: FollowerQueryService
@Mock
private lateinit var statusQueryService: StatusQueryService
@Spy
private val transaction: Transaction = TestTransaction
@InjectMocks
private lateinit var accountApiServiceImpl: AccountApiServiceImpl
private val statusList = listOf(
Status(
id = "",
uri = "",
createdAt = "",
account = Account(
id = "",
username = "",
acct = "",
url = "",
displayName = "",
note = "",
avatar = "",
avatarStatic = "",
header = "",
headerStatic = "",
locked = false,
fields = emptyList(),
emojis = emptyList(),
bot = false,
group = false,
discoverable = true,
createdAt = "",
lastStatusAt = "",
statusesCount = 0,
followersCount = 0,
noindex = false,
moved = false,
suspendex = false,
limited = false,
followingCount = 0
),
content = "",
visibility = Status.Visibility.public,
sensitive = false,
spoilerText = "",
mediaAttachments = emptyList(),
mentions = emptyList(),
tags = emptyList(),
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
url = "https://example.com",
inReplyToId = null,
inReplyToAccountId = null,
language = "ja_JP",
text = "Test",
editedAt = null
)
)
@Test
fun `accountsStatuses 非ログイン時は非公開投稿を見れない`() = runTest {
val userId = 1234L
whenever(
statusQueryService.accountsStatus(
accountId = eq(userId),
maxId = isNull(),
sinceId = isNull(),
minId = isNull(),
limit = eq(20),
onlyMedia = eq(false),
excludeReplies = eq(false),
excludeReblogs = eq(false),
pinned = eq(false),
tagged = isNull(),
includeFollowers = eq(false)
)
).doReturn(
statusList
)
val accountsStatuses = accountApiServiceImpl.accountsStatuses(
userid = userId,
maxId = null,
sinceId = null,
minId = null,
limit = 20,
onlyMedia = false,
excludeReplies = false,
excludeReblogs = false,
pinned = false,
tagged = null,
loginUser = null
)
assertThat(accountsStatuses).hasSize(1)
verify(followerQueryService, never()).alreadyFollow(any(), any())
}
@Test
fun `accountsStatuses ログイン時フォロワーじゃない場合は非公開投稿を見れない`() = runTest {
val userId = 1234L
val loginUser = 1L
whenever(
statusQueryService.accountsStatus(
accountId = eq(userId),
maxId = isNull(),
sinceId = isNull(),
minId = isNull(),
limit = eq(20),
onlyMedia = eq(false),
excludeReplies = eq(false),
excludeReblogs = eq(false),
pinned = eq(false),
tagged = isNull(),
includeFollowers = eq(false)
)
).doReturn(statusList)
whenever(followerQueryService.alreadyFollow(eq(userId), eq(loginUser))).doReturn(false)
val accountsStatuses = accountApiServiceImpl.accountsStatuses(
userid = userId,
maxId = null,
sinceId = null,
minId = null,
limit = 20,
onlyMedia = false,
excludeReplies = false,
excludeReblogs = false,
pinned = false,
tagged = null,
loginUser = loginUser
)
assertThat(accountsStatuses).hasSize(1)
}
@Test
fun `accountsStatuses ログイン時フォロワーの場合は非公開投稿を見れる`() = runTest {
val userId = 1234L
val loginUser = 2L
whenever(
statusQueryService.accountsStatus(
accountId = eq(userId),
maxId = isNull(),
sinceId = isNull(),
minId = isNull(),
limit = eq(20),
onlyMedia = eq(false),
excludeReplies = eq(false),
excludeReblogs = eq(false),
pinned = eq(false),
tagged = isNull(),
includeFollowers = eq(true)
)
).doReturn(statusList)
whenever(followerQueryService.alreadyFollow(eq(userId), eq(loginUser))).doReturn(true)
val accountsStatuses = accountApiServiceImpl.accountsStatuses(
userid = userId,
maxId = null,
sinceId = null,
minId = null,
limit = 20,
onlyMedia = false,
excludeReplies = false,
excludeReblogs = false,
pinned = false,
tagged = null,
loginUser = loginUser
)
assertThat(accountsStatuses).hasSize(1)
}
@Test
fun `follow 既にフォローしている場合は何もしない`() = runTest {
val userId = 1234L
val followeeId = 1L
whenever(followerQueryService.alreadyFollow(eq(followeeId), eq(userId))).doReturn(true)
whenever(followerQueryService.alreadyFollow(eq(userId), eq(followeeId))).doReturn(true)
whenever(userRepository.findFollowRequestsById(eq(followeeId), eq(userId))).doReturn(false)
val follow = accountApiServiceImpl.follow(userId, followeeId)
val expected = Relationship(
id = followeeId.toString(),
following = true,
showingReblogs = true,
notifying = false,
followedBy = true,
blocking = false,
blockedBy = false,
muting = false,
mutingNotifications = false,
requested = false,
domainBlocking = false,
endorsed = false,
note = ""
)
assertThat(follow).isEqualTo(expected)
verify(userService, never()).followRequest(any(), any())
}
@Test
fun `follow 未フォローの場合フォローリクエストが発生する`() = runTest {
val userId = 1234L
val followeeId = 1L
whenever(followerQueryService.alreadyFollow(eq(followeeId), eq(userId))).doReturn(false)
whenever(userService.followRequest(eq(followeeId), eq(userId))).doReturn(true)
whenever(followerQueryService.alreadyFollow(eq(userId), eq(followeeId))).doReturn(true)
whenever(userRepository.findFollowRequestsById(eq(followeeId), eq(userId))).doReturn(false)
val follow = accountApiServiceImpl.follow(userId, followeeId)
val expected = Relationship(
id = followeeId.toString(),
following = true,
showingReblogs = true,
notifying = false,
followedBy = true,
blocking = false,
blockedBy = false,
muting = false,
mutingNotifications = false,
requested = false,
domainBlocking = false,
endorsed = false,
note = ""
)
assertThat(follow).isEqualTo(expected)
verify(userService, times(1)).followRequest(eq(followeeId), eq(userId))
}
@Test
fun `relationships idが長すぎたら省略する`() = runTest {
whenever(followerQueryService.alreadyFollow(any(), any())).doReturn(true)
whenever(userRepository.findFollowRequestsById(any(), any())).doReturn(true)
val relationships = accountApiServiceImpl.relationships(
userid = 1234L,
id = (1..30L).toList(),
withSuspended = false
)
assertThat(relationships).hasSizeLessThanOrEqualTo(20)
}
@Test
fun `relationships id0の場合即時return`() = runTest {
val relationships = accountApiServiceImpl.relationships(
userid = 1234L,
id = emptyList(),
withSuspended = false
)
assertThat(relationships).hasSize(0)
verify(followerQueryService, never()).alreadyFollow(any(), any())
verify(userRepository, never()).findFollowRequestsById(any(), any())
}
@Test
fun `relationships idに指定されたアカウントの関係を取得する`() = runTest {
whenever(followerQueryService.alreadyFollow(any(), any())).doReturn(true)
whenever(userRepository.findFollowRequestsById(any(), any())).doReturn(true)
val relationships = accountApiServiceImpl.relationships(
userid = 1234L,
id = (1..15L).toList(),
withSuspended = false
)
assertThat(relationships).hasSize(15)
}
}