diff --git a/src/intTest/kotlin/mastodon/account/AccountApiTest.kt b/src/intTest/kotlin/mastodon/account/AccountApiTest.kt index fb8d66c6..0c69e626 100644 --- a/src/intTest/kotlin/mastodon/account/AccountApiTest.kt +++ b/src/intTest/kotlin/mastodon/account/AccountApiTest.kt @@ -1,8 +1,10 @@ package mastodon.account import dev.usbharu.hideout.SpringApplication +import dev.usbharu.hideout.core.infrastructure.exposedquery.FollowerQueryServiceImpl import dev.usbharu.hideout.core.infrastructure.exposedquery.UserQueryServiceImpl import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat import org.flywaydb.core.Flyway import org.junit.jupiter.api.AfterAll 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.test.context.support.WithAnonymousUser 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.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.context.jdbc.Sql @@ -29,11 +32,16 @@ import org.springframework.web.context.WebApplicationContext @AutoConfigureMockMvc @Transactional @Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +@Sql("/sql/test-user2.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) class AccountApiTest { + @Autowired + private lateinit var followerQueryServiceImpl: FollowerQueryServiceImpl + @Autowired private lateinit var userQueryServiceImpl: UserQueryServiceImpl + @Autowired private lateinit var context: WebApplicationContext @@ -92,7 +100,7 @@ class AccountApiTest { .asyncDispatch() .andExpect { status { isFound() } } - userQueryServiceImpl.findByNameAndDomain("api-test-user-1", "localhost") + userQueryServiceImpl.findByNameAndDomain("api-test-user-1", "example.com") } @Test @@ -108,7 +116,7 @@ class AccountApiTest { .asyncDispatch() .andExpect { status { isFound() } } - userQueryServiceImpl.findByNameAndDomain("api-test-user-2", "localhost") + userQueryServiceImpl.findByNameAndDomain("api-test-user-2", "example.com") } @Test @@ -159,6 +167,120 @@ class AccountApiTest { .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 { @JvmStatic @AfterAll diff --git a/src/intTest/resources/application.yml b/src/intTest/resources/application.yml index c73fc1f3..51622edd 100644 --- a/src/intTest/resources/application.yml +++ b/src/intTest/resources/application.yml @@ -1,5 +1,5 @@ hideout: - url: "https://localhost:8080" + url: "https://example.com" use-mongodb: true security: jwt: diff --git a/src/intTest/resources/sql/accounts/apiV1AccountsIdFollowPost フォローできる.sql b/src/intTest/resources/sql/accounts/apiV1AccountsIdFollowPost フォローできる.sql new file mode 100644 index 00000000..53ea2830 --- /dev/null +++ b/src/intTest/resources/sql/accounts/apiV1AccountsIdFollowPost フォローできる.sql @@ -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); diff --git a/src/intTest/resources/sql/test-user2.sql b/src/intTest/resources/sql/test-user2.sql new file mode 100644 index 00000000..7b123701 --- /dev/null +++ b/src/intTest/resources/sql/test-user2.sql @@ -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); diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt index d8b7ff7e..c6c20250 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt @@ -1,9 +1,12 @@ 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 open class Document( type: List = emptyList(), + @JsonSetter(nulls = Nulls.AS_EMPTY) override val name: String = "", val mediaType: String, val url: String diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt index 5b63ef5e..8f77d4ae 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt @@ -4,12 +4,11 @@ import dev.usbharu.hideout.activitypub.domain.model.objects.Object open class Image( type: List = emptyList(), - val mediaType: String, + val mediaType: String? = null, val url: String ) : Object( add(type, "Image") ) { - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -25,10 +24,16 @@ open class Image( override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + mediaType.hashCode() + result = 31 * result + (mediaType?.hashCode() ?: 0) result = 31 * result + url.hashCode() 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()}" + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt index 511d3e67..9721cf38 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt @@ -84,7 +84,7 @@ class APRequestServiceImpl( headers { appendAll(headers) append("Signature", sign.signatureHeader) - remove("Host") +// remove("Host") } } contentType(Activity) @@ -173,7 +173,7 @@ class APRequestServiceImpl( append("Accept", Activity) append("Date", date) append("Host", u.host) - append("Digest", "sha-256=$digest") + append("Digest", "SHA-256=$digest") } val sign = httpSignatureSigner.sign( @@ -193,7 +193,7 @@ class APRequestServiceImpl( headers { appendAll(headers) append("Signature", sign.signatureHeader) - remove("Host") +// remove("Host") } setBody(requestBody) contentType(Activity) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt index 65220c41..301ac7ce 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt @@ -99,22 +99,20 @@ class InboxJobProcessor( 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 = - activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor? + val activityPubProcessor = + activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor? - if (activityPubProcessor == null) { - logger.warn("ActivityType {} is not support.", param.type) - 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) + if (activityPubProcessor == null) { + logger.warn("ActivityType {} is not support.", param.type) + 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) } override fun job(): InboxJob = InboxJob diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt index fff97e01..a7efb238 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt @@ -58,8 +58,8 @@ class APUserServiceImpl( url = userUrl, icon = Image( type = emptyList(), - mediaType = "image/png", - url = "$userUrl/icon.png" + mediaType = "image/jpeg", + url = "$userUrl/icon.jpg" ), publicKey = Key( id = userEntity.keyId, @@ -124,8 +124,8 @@ class APUserServiceImpl( url = id, icon = Image( type = emptyList(), - mediaType = "image/png", - url = "$id/icon.png" + mediaType = "image/jpeg", + url = "$id/icon.jpg" ), publicKey = Key( id = userEntity.keyId, diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt index 59caf58d..eb19802f 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt @@ -180,17 +180,23 @@ class SecurityConfig { authorize(POST, "/inbox", permitAll) authorize(POST, "/users/*/inbox", permitAll) + authorize(GET, "/users/*", permitAll) + authorize(GET, "/users/*/posts/*", permitAll) authorize(POST, "/api/v1/apps", permitAll) authorize(GET, "/api/v1/instance/**", permitAll) authorize(POST, "/api/v1/accounts", permitAll) authorize("/auth/sign_up", hasRole("ANONYMOUS")) - authorize(GET, "/files", permitAll) + authorize(GET, "/files/*", permitAll) authorize(GET, "/users/*/icon.jpg", permitAll) authorize(GET, "/users/*/header.jpg", permitAll) 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/statuses", hasAnyScope("write", "write:statuses")) diff --git a/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt b/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt index 3438d428..74be00ff 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt @@ -2,19 +2,24 @@ package dev.usbharu.hideout.application.infrastructure.exposed import dev.usbharu.hideout.application.external.Transaction 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.springframework.stereotype.Service +import java.sql.Connection @Service class ExposedTransaction : Transaction { override suspend fun transaction(block: suspend () -> T): T { - return newSuspendedTransaction(MDCContext()) { + return newSuspendedTransaction(MDCContext(), transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) { + addLogger(StdOutSqlLogger) block() } } override suspend fun transaction(transactionLevel: Int, block: suspend () -> T): T { return newSuspendedTransaction(MDCContext(), transactionIsolation = transactionLevel) { + addLogger(StdOutSqlLogger) block() } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt index a03272c4..ff8e07e8 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt @@ -8,6 +8,7 @@ import kjob.core.dsl.JobRegisterContext import kjob.core.dsl.KJobFunctions import kjob.core.kjob import org.jetbrains.exposed.sql.transactions.TransactionManager +import org.slf4j.MDC import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Service @@ -35,8 +36,13 @@ class KJobJobQueueWorkerService(private val jobQueueProcessorList: List { + 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>): List { val statuses = pairs.map { it.first } return pairs @@ -111,11 +169,11 @@ private fun toStatus(it: ResultRow) = Status( ), content = it[Posts.text], visibility = when (it[Posts.visibility]) { - 0 -> Status.Visibility.public - 1 -> Status.Visibility.unlisted - 2 -> Status.Visibility.private - 3 -> Status.Visibility.direct - else -> Status.Visibility.public + 0 -> public + 1 -> unlisted + 2 -> private + 3 -> direct + else -> public }, sensitive = it[Posts.sensitive], spoilerText = it[Posts.overview].orEmpty(), diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt index 46a03fbe..a7f3741c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt @@ -3,8 +3,11 @@ package dev.usbharu.hideout.mastodon.interfaces.api.account import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.controller.mastodon.generated.AccountApi 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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.runBlocking import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -18,6 +21,19 @@ class MastodonAccountApiController( private val accountApiService: AccountApiService, private val transaction: Transaction ) : AccountApi { + + override suspend fun apiV1AccountsIdFollowPost( + id: String, + followRequestBody: FollowRequestBody? + ): ResponseEntity { + val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt + + return ResponseEntity.ok(accountApiService.follow(principal.getClaim("uid").toLong(), id.toLong())) + } + + override suspend fun apiV1AccountsIdGet(id: String): ResponseEntity = + ResponseEntity.ok(accountApiService.account(id.toLong())) + override suspend fun apiV1AccountsVerifyCredentialsGet(): ResponseEntity { val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt @@ -42,4 +58,49 @@ class MastodonAccountApiController( httpHeaders.location = URI("/users/$username") 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> = runBlocking { + val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt + + val userid = principal.getClaim("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?, + withSuspended: Boolean + ): ResponseEntity> = runBlocking { + val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt + + val userid = principal.getClaim("uid").toLong() + + ResponseEntity.ok( + accountApiService.relationships(userid, id.orEmpty().mapNotNull { it.toLongOrNull() }, withSuspended) + .asFlow() + ) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt index a5777360..2b4e2a31 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt @@ -6,4 +6,34 @@ import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery interface StatusQueryService { suspend fun findByPostIds(ids: List): List suspend fun findByPostIdsWithMediaIds(statusQueries: List): List + + /** + * アカウントの投稿一覧を取得します + * + * @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 } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt index 9fc44262..a3315107 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt @@ -1,27 +1,87 @@ 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.UserCreateDto 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.CredentialAccount -import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccountSource -import dev.usbharu.hideout.domain.mastodon.model.generated.Role +import dev.usbharu.hideout.domain.mastodon.model.generated.* +import dev.usbharu.hideout.mastodon.query.StatusQueryService +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import kotlin.math.min @Service 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 + suspend fun verifyCredentials(userid: Long): CredentialAccount 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, withSuspended: Boolean): List } @Service class AccountApiServiceImpl( private val accountService: AccountService, 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 { + 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 { + 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 { val account = accountService.findById(userid) from(account) @@ -31,6 +91,75 @@ class AccountApiServiceImpl( 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, withSuspended: Boolean): List = + 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 { return CredentialAccount( id = account.id, @@ -68,4 +197,8 @@ class AccountApiServiceImpl( role = Role(0, "Admin", "", 32) ) } + + companion object { + private val logger = LoggerFactory.getLogger(AccountApiServiceImpl::class.java) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountService.kt index a7f5f766..72050167 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountService.kt @@ -1,5 +1,6 @@ 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.domain.mastodon.model.generated.Account import org.springframework.stereotype.Service @@ -10,9 +11,14 @@ interface AccountService { } @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 { val findById = userQueryService.findById(id) + val userUrl = applicationConfig.url.toString() + "/users/" + findById.id.toString() + return Account( id = findById.id.toString(), username = findById.name, @@ -20,10 +26,10 @@ class AccountServiceImpl(private val userQueryService: UserQueryService) : Accou url = findById.url, displayName = findById.screenName, note = findById.description, - avatar = findById.url + "/icon.jpg", - avatarStatic = findById.url + "/icon.jpg", - header = findById.url + "/header.jpg", - headerStatic = findById.url + "/header.jpg", + avatar = "$userUrl/icon.jpg", + avatarStatic = "$userUrl/icon.jpg", + header = "$userUrl/header.jpg", + headerStatic = "$userUrl/header.jpg", locked = false, fields = emptyList(), emojis = emptyList(), diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/app/AppApiService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/app/AppApiService.kt index 6d7d463e..7324dd33 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/app/AppApiService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/app/AppApiService.kt @@ -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.stereotype.Service import java.time.Duration -import java.time.Instant import java.util.* @Service @@ -46,7 +45,7 @@ class AppApiServiceImpl( .tokenSettings( TokenSettings.builder() .accessTokenTimeToLive( - Duration.ofSeconds((Instant.MAX.epochSecond - Instant.now().epochSecond - 10000) / 1000) + Duration.ofSeconds(31536000000) ) .build() ) @@ -60,7 +59,8 @@ class AppApiServiceImpl( "invalid-vapid-key", appsRequest.website, id, - clientSecret + clientSecret, + appsRequest.redirectUris ) } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9565e8cd..daff34db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,7 +36,7 @@ spring: max-request-size: 40MB h2: console: - enabled: false + enabled: true server: tomcat: basedir: tomcat diff --git a/src/main/resources/db/migration/V1__Init_DB.sql b/src/main/resources/db/migration/V1__Init_DB.sql index e0188588..1440fb61 100644 --- a/src/main/resources/db/migration/V1__Init_DB.sql +++ b/src/main/resources/db/migration/V1__Init_DB.sql @@ -31,7 +31,7 @@ create table if not exists users "following" varchar(1000) null, followers varchar(1000) 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 ); create table if not exists follow_requests diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 5e4e2bc3..9ba872ba 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -4,7 +4,7 @@ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{x-request-id}] %logger{36} - %msg%n - + @@ -12,7 +12,7 @@ - + diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index 6f93fa36..3049c1d9 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -206,6 +206,161 @@ paths: 200: 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: get: tags: @@ -1314,6 +1469,8 @@ components: type: string client_secret: type: string + redirect_uri: + type: string required: - name - vapid_key @@ -1333,6 +1490,65 @@ components: - client_name - 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: OAuth2: type: oauth2 diff --git a/src/test/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiControllerTest.kt b/src/test/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiControllerTest.kt index ff114d93..895f33a4 100644 --- a/src/test/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiControllerTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiControllerTest.kt @@ -125,4 +125,20 @@ class MastodonAccountApiControllerTest { .andExpect { header { string("location", "/users/hoge") } } .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() } } + + } } diff --git a/src/test/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiServiceImplTest.kt new file mode 100644 index 00000000..5cc6a833 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiServiceImplTest.kt @@ -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) + } +}