diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/HttpClientConfig.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/HttpClientConfig.kt index f3b036a1..01209557 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/HttpClientConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/HttpClientConfig.kt @@ -14,13 +14,13 @@ class HttpClientConfig { @Bean fun httpClient(buildProperties: BuildProperties, applicationConfig: ApplicationConfig): HttpClient = HttpClient(CIO).config { - install(Logging) { - logger = Logger.DEFAULT - level = LogLevel.ALL - } - install(HttpCache) { - } - expectSuccess = true + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + install(HttpCache) { + } + expectSuccess = true install(UserAgent) { agent = "Hideout/${buildProperties.version} (${applicationConfig.url})" } 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 b79bd78f..075fc1bb 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt @@ -272,7 +272,9 @@ class SecurityConfig { fun jwtTokenCustomizer(): OAuth2TokenCustomizer { return OAuth2TokenCustomizer { context: JwtEncodingContext -> - if (OAuth2TokenType.ACCESS_TOKEN == context.tokenType && context.authorization?.authorizationGrantType == AuthorizationGrantType.AUTHORIZATION_CODE) { + if (OAuth2TokenType.ACCESS_TOKEN == context.tokenType && + context.authorization?.authorizationGrantType == AuthorizationGrantType.AUTHORIZATION_CODE + ) { val userDetailsImpl = context.getPrincipal().principal as UserDetailsImpl context.claims.claim("uid", userDetailsImpl.id.toString()) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt index 221c92e5..f5e368db 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt @@ -2,6 +2,7 @@ package dev.usbharu.hideout.core.domain.exception.media import java.io.Serial +@Suppress("UnnecessaryAbstractClass") abstract class MediaException : RuntimeException { constructor() : super() constructor(message: String?) : super(message) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/ExposedNotificationRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/ExposedNotificationRepository.kt new file mode 100644 index 00000000..03e28d0a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/ExposedNotificationRepository.kt @@ -0,0 +1,87 @@ +package dev.usbharu.hideout.core.domain.model.notification + +import dev.usbharu.hideout.application.service.id.IdGenerateService +import dev.usbharu.hideout.core.infrastructure.exposedrepository.AbstractRepository +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Actors +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Posts +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Reactions +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.javatime.timestamp +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Repository + +@Repository +class ExposedNotificationRepository(private val idGenerateService: IdGenerateService) : NotificationRepository, + AbstractRepository() { + override val logger: Logger + get() = Companion.logger + + override suspend fun generateId(): Long = idGenerateService.generateId() + + override suspend fun save(notification: Notification): Notification = query { + val singleOrNull = Notifications.select { + Notifications.id eq notification.id + }.forUpdate().singleOrNull() + if (singleOrNull == null) { + Notifications.insert { + it[id] = notification.id + it[type] = notification.type + it[userId] = notification.userId + it[sourceActorId] = notification.sourceActorId + it[postId] = notification.postId + it[text] = notification.text + it[reactionId] = notification.reactionId + it[createdAt] = notification.createdAt + } + } else { + Notifications.update({ Notifications.id eq notification.id }) { + it[type] = notification.type + it[userId] = notification.userId + it[sourceActorId] = notification.sourceActorId + it[postId] = notification.postId + it[text] = notification.text + it[reactionId] = notification.reactionId + it[createdAt] = notification.createdAt + } + } + notification + } + + override suspend fun findById(id: Long): Notification? = query { + Notifications.select { Notifications.id eq id }.singleOrNull()?.toNotifications() + } + + override suspend fun deleteById(id: Long) { + Notifications.deleteWhere { Notifications.id eq id } + } + + companion object { + private val logger = LoggerFactory.getLogger(ExposedNotificationRepository::class.java) + } +} + +fun ResultRow.toNotifications() = Notification( + id = this[Notifications.id], + type = this[Notifications.type], + userId = this[Notifications.userId], + sourceActorId = this[Notifications.sourceActorId], + postId = this[Notifications.postId], + text = this[Notifications.text], + reactionId = this[Notifications.reactionId], + createdAt = this[Notifications.createdAt], +) + +object Notifications : Table("notifications") { + val id = long("id") + val type = varchar("type", 100) + val userId = long("user_id").references(Actors.id) + val sourceActorId = long("source_actor_id").references(Actors.id).nullable() + val postId = long("post_id").references(Posts.id).nullable() + val text = varchar("text", 3000).nullable() + val reactionId = long("reaction_id").references(Reactions.id).nullable() + val createdAt = timestamp("created_at") + + override val primaryKey: PrimaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/Notification.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/Notification.kt new file mode 100644 index 00000000..395c143d --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/Notification.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.core.domain.model.notification + +import java.time.Instant + +data class Notification( + val id: Long, + val type: String, + val userId: Long, + val sourceActorId: Long?, + val postId: Long?, + val text: String?, + val reactionId: Long?, + val createdAt: Instant +) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/NotificationRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/NotificationRepository.kt new file mode 100644 index 00000000..97f99fa9 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/notification/NotificationRepository.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.core.domain.model.notification + +interface NotificationRepository { + suspend fun generateId(): Long + suspend fun save(notification: Notification): Notification + suspend fun findById(id: Long): Notification? + suspend fun deleteById(id: Long) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt index df35e349..64901759 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt @@ -4,7 +4,7 @@ import dev.usbharu.hideout.core.domain.model.emoji.Emoji import org.springframework.stereotype.Repository @Repository -@Suppress("FunctionMaxLength", "TooManyFunction") +@Suppress("FunctionMaxLength", "TooManyFunctions") interface ReactionRepository { suspend fun generateId(): Long suspend fun save(reaction: Reaction): Reaction @@ -13,6 +13,7 @@ interface ReactionRepository { suspend fun deleteByActorId(actorId: Long): Int suspend fun deleteByPostIdAndActorId(postId: Long, actorId: Long) suspend fun deleteByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji) + suspend fun findById(id: Long): Reaction? suspend fun findByPostId(postId: Long): List suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction? suspend fun existByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Boolean diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepository.kt index 7dca9785..843e9eac 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepository.kt @@ -43,6 +43,7 @@ interface RelationshipRepository { ignoreFollowRequest: Boolean ): List + @Suppress("FunctionMaxLength") suspend fun findByActorIdAntMutingAndMaxIdAndSinceId( actorId: Long, muting: Boolean, diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt index 50887de2..ee6cbf3b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt @@ -101,6 +101,10 @@ class ReactionRepositoryImpl( } } + override suspend fun findById(id: Long): Reaction? = query { + return@query Reactions.leftJoin(CustomEmojis).select { Reactions.id eq id }.singleOrNull()?.toReaction() + } + override suspend fun findByPostId(postId: Long): List = query { return@query Reactions.leftJoin(CustomEmojis).select { Reactions.postId eq postId }.map { it.toReaction() } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationRequest.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationRequest.kt new file mode 100644 index 00000000..0ea02754 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationRequest.kt @@ -0,0 +1,120 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification +import java.time.Instant + +sealed class NotificationRequest(open val userId: Long, open val sourceActorId: Long?, val type: String) { + abstract fun buildNotification(id: Long, createdAt: Instant): Notification +} + +interface PostId { + val postId: Long +} + +data class MentionNotificationRequest( + override val userId: Long, + override val sourceActorId: Long, + override val postId: Long +) : NotificationRequest( + userId, + sourceActorId, + "mention" +), + PostId { + override fun buildNotification(id: Long, createdAt: Instant): Notification = Notification( + id = id, + type = type, + userId = userId, + sourceActorId = sourceActorId, + postId = postId, + text = null, + reactionId = null, + createdAt = createdAt + ) +} + +data class PostNotificationRequest( + override val userId: Long, + override val sourceActorId: Long, + override val postId: Long + +) : NotificationRequest(userId, sourceActorId, "post"), PostId { + override fun buildNotification(id: Long, createdAt: Instant): Notification = Notification( + id = id, + type = type, + userId = userId, + sourceActorId = sourceActorId, + postId = postId, + text = null, + reactionId = null, + createdAt = createdAt + ) +} + +data class RepostNotificationRequest( + override val userId: Long, + override val sourceActorId: Long, + override val postId: Long +) : NotificationRequest(userId, sourceActorId, "repost"), PostId { + override fun buildNotification(id: Long, createdAt: Instant): Notification = Notification( + id = id, + type = type, + userId = userId, + sourceActorId = sourceActorId, + postId = postId, + text = null, + reactionId = null, + createdAt = createdAt + ) +} + +data class FollowNotificationRequest( + override val userId: Long, + override val sourceActorId: Long +) : NotificationRequest(userId, sourceActorId, "follow") { + override fun buildNotification(id: Long, createdAt: Instant): Notification = Notification( + id = id, + type = type, + userId = userId, + sourceActorId = sourceActorId, + postId = null, + text = null, + reactionId = null, + createdAt = createdAt + ) +} + +data class FollowRequestNotificationRequest( + override val userId: Long, + override val sourceActorId: Long +) : NotificationRequest(userId, sourceActorId, "follow-request") { + override fun buildNotification(id: Long, createdAt: Instant): Notification = Notification( + id = id, + type = type, + userId = userId, + sourceActorId = sourceActorId, + postId = null, + text = null, + reactionId = null, + createdAt = createdAt + ) +} + +data class ReactionNotificationRequest( + override val userId: Long, + override val sourceActorId: Long, + override val postId: Long, + val reactionId: Long + +) : NotificationRequest(userId, sourceActorId, "reaction"), PostId { + override fun buildNotification(id: Long, createdAt: Instant): Notification = Notification( + id = id, + type = type, + userId = userId, + sourceActorId = sourceActorId, + postId = postId, + text = null, + reactionId = reactionId, + createdAt = createdAt + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationService.kt new file mode 100644 index 00000000..aa5e3a9e --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationService.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification + +interface NotificationService { + suspend fun publishNotify(notificationRequest: NotificationRequest): Notification? + suspend fun unpublishNotify(notificationId: Long) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationServiceImpl.kt new file mode 100644 index 00000000..f2c049a7 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationServiceImpl.kt @@ -0,0 +1,97 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.application.config.ApplicationConfig +import dev.usbharu.hideout.core.domain.model.actor.ActorRepository +import dev.usbharu.hideout.core.domain.model.notification.Notification +import dev.usbharu.hideout.core.domain.model.notification.NotificationRepository +import dev.usbharu.hideout.core.domain.model.post.PostRepository +import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository +import dev.usbharu.hideout.core.domain.model.relationship.RelationshipRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class NotificationServiceImpl( + private val relationshipNotificationManagementService: RelationshipNotificationManagementService, + private val relationshipRepository: RelationshipRepository, + private val notificationStoreList: List, + private val notificationRepository: NotificationRepository, + private val actorRepository: ActorRepository, + private val postRepository: PostRepository, + private val reactionRepository: ReactionRepository, + private val applicationConfig: ApplicationConfig +) : NotificationService { + override suspend fun publishNotify(notificationRequest: NotificationRequest): Notification? { + logger.debug("NOTIFICATION REQUEST user: {} type: {}", notificationRequest.userId, notificationRequest.type) + logger.trace("NotificationRequest: {}", notificationRequest) + + val user = actorRepository.findById(notificationRequest.userId) + if (user == null || user.domain != applicationConfig.url.host) { + logger.debug("NOTIFICATION REQUEST is rejected. (Remote Actor or user not found.)") + return null + } + + // とりあえず個人間のRelationshipに基づいてきめる。今後増やす + if (!relationship(notificationRequest)) { + logger.debug("NOTIFICATION REQUEST is rejected. (relationship)") + return null + } + + val id = notificationRepository.generateId() + val createdAt = Instant.now() + + val notification = notificationRequest.buildNotification(id, createdAt) + + val savedNotification = notificationRepository.save(notification) + + val sourceActor = savedNotification.sourceActorId?.let { actorRepository.findById(it) } + + val post = savedNotification.postId?.let { postRepository.findById(it) } + val reaction = savedNotification.reactionId?.let { reactionRepository.findById(it) } + + logger.info( + "NOTIFICATION id: {} user: {} type: {}", + savedNotification.id, + savedNotification.userId, + savedNotification.type + ) + + logger.debug("push to {} notification store.", notificationStoreList.size) + for (it in notificationStoreList) { + @Suppress("TooGenericExceptionCaught") + try { + it.publishNotification(savedNotification, user, sourceActor, post, reaction) + } catch (e: Exception) { + logger.warn("FAILED Publish to notification.", e) + } + } + logger.debug("SUCCESS Notification id: {}", savedNotification.id) + + return savedNotification + } + + override suspend fun unpublishNotify(notificationId: Long) { + notificationRepository.deleteById(notificationId) + for (notificationStore in notificationStoreList) { + notificationStore.unpulishNotification(notificationId) + } + } + + /** + * 個人間のRelationshipに基づいて通知を送信するか判断します + * + * @param notificationRequest + * @return trueの場合送信する + */ + private suspend fun relationship(notificationRequest: NotificationRequest): Boolean { + val targetActorId = notificationRequest.sourceActorId ?: return true + val relationship = + relationshipRepository.findByUserIdAndTargetUserId(notificationRequest.userId, targetActorId) ?: return true + return relationshipNotificationManagementService.sendNotification(relationship, notificationRequest) + } + + companion object { + private val logger = LoggerFactory.getLogger(NotificationServiceImpl::class.java) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationStore.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationStore.kt new file mode 100644 index 00000000..6528e5ba --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/NotificationStore.kt @@ -0,0 +1,18 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.actor.Actor +import dev.usbharu.hideout.core.domain.model.notification.Notification +import dev.usbharu.hideout.core.domain.model.post.Post +import dev.usbharu.hideout.core.domain.model.reaction.Reaction + +interface NotificationStore { + suspend fun publishNotification( + notification: Notification, + user: Actor, + sourceActor: Actor?, + post: Post?, + reaction: Reaction? + ): Boolean + + suspend fun unpulishNotification(notificationId: Long): Boolean +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementService.kt new file mode 100644 index 00000000..facd0a60 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementService.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.relationship.Relationship + +interface RelationshipNotificationManagementService { + fun sendNotification(relationship: Relationship, notificationRequest: NotificationRequest): Boolean +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementServiceImpl.kt new file mode 100644 index 00000000..ca1600e0 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementServiceImpl.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.relationship.Relationship +import org.springframework.stereotype.Service + +@Service +class RelationshipNotificationManagementServiceImpl : RelationshipNotificationManagementService { + override fun sendNotification(relationship: Relationship, notificationRequest: NotificationRequest): Boolean = + relationship.muting.not() +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/post/DefaultPostContentFormatter.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/post/DefaultPostContentFormatter.kt index 21252a28..3f063845 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/post/DefaultPostContentFormatter.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/post/DefaultPostContentFormatter.kt @@ -67,13 +67,13 @@ class DefaultPostContentFormatter(private val policyFactory: PolicyFactory) : Po private fun printHtml(element: Elements): String { return element.joinToString("\n\n") { - it.childNodes().joinToString("") { - if (it is Element && it.tagName() == "br") { + it.childNodes().joinToString("") { node -> + if (node is Element && node.tagName() == "br") { "\n" - } else if (it is Element) { - it.text() - } else if (it is TextNode) { - it.text() + } else if (node is Element) { + node.text() + } else if (node is TextNode) { + node.text() } else { "" } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt index 6e58fe8d..a6878729 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt @@ -5,7 +5,6 @@ import dev.usbharu.hideout.activitypub.service.activity.delete.APSendDeleteServi import dev.usbharu.hideout.core.domain.exception.UserNotFoundException import dev.usbharu.hideout.core.domain.exception.resource.DuplicateException import dev.usbharu.hideout.core.domain.exception.resource.PostNotFoundException -import dev.usbharu.hideout.core.domain.model.actor.Actor import dev.usbharu.hideout.core.domain.model.actor.ActorRepository import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.domain.model.post.PostRepository @@ -38,7 +37,7 @@ class PostServiceImpl( logger.info("START Create Remote Post user: {}, remote url: {}", post.actorId, post.apId) val actor = actorRepository.findById(post.actorId) ?: throw UserNotFoundException("${post.actorId} was not found.") - val createdPost = internalCreate(post, false, actor) + val createdPost = internalCreate(post, false) logger.info("SUCCESS Create Remote Post url: {}", createdPost.url) return createdPost } @@ -79,11 +78,10 @@ class PostServiceImpl( actorRepository.save(actor.copy(postsCount = 0, lastPostDate = null)) } - private suspend fun internalCreate(post: Post, isLocal: Boolean, actor: Actor): Post { + private suspend fun internalCreate(post: Post, isLocal: Boolean): Post { return try { val save = postRepository.save(post) timelineService.publishTimeline(post, isLocal) -// actorRepository.save(actor.incrementPostsCount()) save } catch (_: DuplicateException) { postRepository.findByApId(post.apId) ?: throw PostNotFoundException.withApId(post.apId) @@ -105,7 +103,7 @@ class PostServiceImpl( replyId = post.repolyId, repostId = post.repostId, ) - return internalCreate(createPost, isLocal, user) + return internalCreate(createPost, isLocal) } companion object { diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt index 927dd9fb..48191c55 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt @@ -3,8 +3,11 @@ package dev.usbharu.hideout.core.service.reaction import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService import dev.usbharu.hideout.core.domain.exception.resource.DuplicateException import dev.usbharu.hideout.core.domain.model.emoji.Emoji +import dev.usbharu.hideout.core.domain.model.post.PostRepository import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository +import dev.usbharu.hideout.core.service.notification.NotificationService +import dev.usbharu.hideout.core.service.notification.ReactionNotificationRequest import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -12,7 +15,9 @@ import org.springframework.stereotype.Service @Service class ReactionServiceImpl( private val reactionRepository: ReactionRepository, - private val apReactionService: APReactionService + private val apReactionService: APReactionService, + private val notificationService: NotificationService, + private val postRepository: PostRepository ) : ReactionService { override suspend fun receiveReaction( emoji: Emoji, @@ -23,7 +28,16 @@ class ReactionServiceImpl( reactionRepository.deleteByPostIdAndActorId(postId, actorId) } try { - reactionRepository.save(Reaction(reactionRepository.generateId(), emoji, postId, actorId)) + val reaction = reactionRepository.save(Reaction(reactionRepository.generateId(), emoji, postId, actorId)) + + notificationService.publishNotify( + ReactionNotificationRequest( + postRepository.findById(postId)!!.actorId, + actorId, + postId, + reaction.id + ) + ) } catch (_: DuplicateException) { } } @@ -49,6 +63,10 @@ class ReactionServiceImpl( val reaction = Reaction(reactionRepository.generateId(), emoji, postId, actorId) reactionRepository.save(reaction) apReactionService.reaction(reaction) + + val id = postRepository.findById(postId)!!.actorId + + notificationService.publishNotify(ReactionNotificationRequest(id, actorId, postId, reaction.id)) } override suspend fun removeReaction(actorId: Long, postId: Long) { diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImpl.kt index cd83150e..4de70434 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImpl.kt @@ -12,6 +12,9 @@ import dev.usbharu.hideout.core.domain.model.actor.ActorRepository import dev.usbharu.hideout.core.domain.model.relationship.Relationship import dev.usbharu.hideout.core.domain.model.relationship.RelationshipRepository import dev.usbharu.hideout.core.service.follow.SendFollowDto +import dev.usbharu.hideout.core.service.notification.FollowNotificationRequest +import dev.usbharu.hideout.core.service.notification.FollowRequestNotificationRequest +import dev.usbharu.hideout.core.service.notification.NotificationService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -24,7 +27,8 @@ class RelationshipServiceImpl( private val apSendAcceptService: ApSendAcceptService, private val apSendRejectService: ApSendRejectService, private val apSendUndoService: APSendUndoService, - private val actorRepository: ActorRepository + private val actorRepository: ActorRepository, + private val notificationService: NotificationService ) : RelationshipService { override suspend fun followRequest(actorId: Long, targetId: Long) { logger.info("START Follow Request userId: {} targetId: {}", actorId, targetId) @@ -82,6 +86,8 @@ class RelationshipServiceImpl( val target = actorRepository.findById(targetId) ?: throw UserNotFoundException.withId(targetId) if (target.locked.not()) { acceptFollowRequest(targetId, actorId) + } else { + notificationService.publishNotify(FollowRequestNotificationRequest(targetId, actorId)) } } @@ -185,6 +191,7 @@ class RelationshipServiceImpl( if (isRemoteActor(remoteActor)) { apSendAcceptService.sendAcceptFollow(user, remoteActor) } + notificationService.publishNotify(FollowNotificationRequest(actorId, targetId)) } override suspend fun rejectFollowRequest(actorId: Long, targetId: Long) { diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/MastodonNotification.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/MastodonNotification.kt new file mode 100644 index 00000000..baba08e8 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/MastodonNotification.kt @@ -0,0 +1,18 @@ +package dev.usbharu.hideout.mastodon.domain.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.time.Instant + +@Document +data class MastodonNotification( + @Id + val id: Long, + val userId: Long, + val type: NotificationType, + val createdAt: Instant, + val accountId: Long, + val statusId: Long?, + val reportId: Long?, + val relationshipServeranceEvent: Long? +) diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/MastodonNotificationRepository.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/MastodonNotificationRepository.kt new file mode 100644 index 00000000..8d903a56 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/MastodonNotificationRepository.kt @@ -0,0 +1,21 @@ +package dev.usbharu.hideout.mastodon.domain.model + +interface MastodonNotificationRepository { + suspend fun save(mastodonNotification: MastodonNotification): MastodonNotification + suspend fun deleteById(id: Long) + suspend fun findById(id: Long): MastodonNotification? + + @Suppress("LongParameterList", "FunctionMaxLength") + suspend fun findByUserIdAndMaxIdAndMinIdAndSinceIdAndInTypesAndInSourceActorId( + loginUser: Long, + maxId: Long?, + minId: Long?, + sinceId: Long?, + limit: Int, + typesTmp: MutableList, + accountId: List + ): List + + suspend fun deleteByUserId(userId: Long) + suspend fun deleteByUserIdAndId(userId: Long, id: Long) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/NotificationType.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/NotificationType.kt new file mode 100644 index 00000000..ff762881 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/model/NotificationType.kt @@ -0,0 +1,33 @@ +package dev.usbharu.hideout.mastodon.domain.model + +@Suppress("EnumEntryName", "EnumNaming", "EnumEntryNameCase") +enum class NotificationType { + mention, + status, + reblog, + follow, + follow_request, + favourite, + poll, + update, + admin_sign_up, + admin_report, + severed_relationships; + + companion object { + fun parse(string: String): NotificationType? = when (string) { + "mention" -> mention + "status" -> status + "reblog" -> reblog + "follow" -> follow + "follow_request" -> follow_request + "favourite" -> favourite + "poll" -> poll + "update" -> update + "admin.sign_up" -> admin_sign_up + "admin.report" -> admin_report + "servered_relationships" -> severed_relationships + else -> null + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedrepository/ExposedMastodonNotificationRepository.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedrepository/ExposedMastodonNotificationRepository.kt new file mode 100644 index 00000000..14279e69 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedrepository/ExposedMastodonNotificationRepository.kt @@ -0,0 +1,128 @@ +package dev.usbharu.hideout.mastodon.infrastructure.exposedrepository + +import dev.usbharu.hideout.core.infrastructure.exposedrepository.AbstractRepository +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Timelines +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotification +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotificationRepository +import dev.usbharu.hideout.mastodon.domain.model.NotificationType +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.javatime.timestamp +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Repository + +@Repository +@Qualifier("jdbc") +@ConditionalOnProperty("hideout.use-mongodb", havingValue = "false", matchIfMissing = true) +class ExposedMastodonNotificationRepository : MastodonNotificationRepository, AbstractRepository() { + override val logger: Logger + get() = Companion.logger + + override suspend fun save(mastodonNotification: MastodonNotification): MastodonNotification = query { + val singleOrNull = + MastodonNotifications.select { MastodonNotifications.id eq mastodonNotification.id }.singleOrNull() + if (singleOrNull == null) { + MastodonNotifications.insert { + it[MastodonNotifications.id] = mastodonNotification.id + it[MastodonNotifications.type] = mastodonNotification.type.name + it[MastodonNotifications.createdAt] = mastodonNotification.createdAt + it[MastodonNotifications.accountId] = mastodonNotification.accountId + it[MastodonNotifications.statusId] = mastodonNotification.statusId + it[MastodonNotifications.reportId] = mastodonNotification.reportId + it[MastodonNotifications.relationshipServeranceEventId] = + mastodonNotification.relationshipServeranceEvent + } + } else { + MastodonNotifications.update({ MastodonNotifications.id eq mastodonNotification.id }) { + it[MastodonNotifications.type] = mastodonNotification.type.name + it[MastodonNotifications.createdAt] = mastodonNotification.createdAt + it[MastodonNotifications.accountId] = mastodonNotification.accountId + it[MastodonNotifications.statusId] = mastodonNotification.statusId + it[MastodonNotifications.reportId] = mastodonNotification.reportId + it[MastodonNotifications.relationshipServeranceEventId] = + mastodonNotification.relationshipServeranceEvent + } + } + mastodonNotification + } + + override suspend fun deleteById(id: Long): Unit = query { + MastodonNotifications.deleteWhere { + MastodonNotifications.id eq id + } + } + + override suspend fun findById(id: Long): MastodonNotification? = query { + MastodonNotifications.select { MastodonNotifications.id eq id }.singleOrNull()?.toMastodonNotification() + } + + override suspend fun findByUserIdAndMaxIdAndMinIdAndSinceIdAndInTypesAndInSourceActorId( + loginUser: Long, + maxId: Long?, + minId: Long?, + sinceId: Long?, + limit: Int, + typesTmp: MutableList, + accountId: List + ): List = query { + val query = MastodonNotifications.select { + MastodonNotifications.userId eq loginUser + } + + if (maxId != null) { + query.andWhere { MastodonNotifications.id lessEq maxId } + } + if (minId != null) { + query.andWhere { MastodonNotifications.id greaterEq minId } + } + if (sinceId != null) { + query.andWhere { MastodonNotifications.id greaterEq sinceId } + } + val result = query + .limit(limit) + .orderBy(Timelines.createdAt, SortOrder.DESC) + + return@query result.map { it.toMastodonNotification() } + } + + override suspend fun deleteByUserId(userId: Long) { + MastodonNotifications.deleteWhere { + MastodonNotifications.userId eq userId + } + } + + override suspend fun deleteByUserIdAndId(userId: Long, id: Long) { + MastodonNotifications.deleteWhere { + MastodonNotifications.userId eq userId and (MastodonNotifications.id eq id) + } + } + + companion object { + private val logger = LoggerFactory.getLogger(ExposedMastodonNotificationRepository::class.java) + } +} + +fun ResultRow.toMastodonNotification(): MastodonNotification = MastodonNotification( + id = this[MastodonNotifications.id], + userId = this[MastodonNotifications.userId], + type = NotificationType.valueOf(this[MastodonNotifications.type]), + createdAt = this[MastodonNotifications.createdAt], + accountId = this[MastodonNotifications.accountId], + statusId = this[MastodonNotifications.statusId], + reportId = this[MastodonNotifications.reportId], + relationshipServeranceEvent = this[MastodonNotifications.relationshipServeranceEventId], +) + +object MastodonNotifications : Table("mastodon_notifications") { + val id = long("id") + val userId = long("user_id") + val type = varchar("type", 100) + val createdAt = timestamp("created_at") + val accountId = long("account_id") + val statusId = long("status_id").nullable() + val reportId = long("report_id").nullable() + val relationshipServeranceEventId = long("relationship_serverance_event_id").nullable() +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/mongorepository/MongoMastodonNotificationRepository.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/mongorepository/MongoMastodonNotificationRepository.kt new file mode 100644 index 00000000..dcbe2c81 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/mongorepository/MongoMastodonNotificationRepository.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.mastodon.infrastructure.mongorepository + +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotification +import org.springframework.data.mongodb.repository.MongoRepository + +interface MongoMastodonNotificationRepository : MongoRepository { + + fun deleteByUserId(userId: Long): Long + + fun deleteByIdAndUserId(id: Long, userId: Long): Long +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/mongorepository/MongoMastodonNotificationRepositoryWrapper.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/mongorepository/MongoMastodonNotificationRepositoryWrapper.kt new file mode 100644 index 00000000..96c7a278 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/mongorepository/MongoMastodonNotificationRepositoryWrapper.kt @@ -0,0 +1,67 @@ +package dev.usbharu.hideout.mastodon.infrastructure.mongorepository + +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotification +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotificationRepository +import dev.usbharu.hideout.mastodon.domain.model.NotificationType +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.data.domain.Sort +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.stereotype.Repository +import kotlin.jvm.optionals.getOrNull + +@Repository +@ConditionalOnProperty("hideout.use-mongodb", havingValue = "true", matchIfMissing = false) +class MongoMastodonNotificationRepositoryWrapper( + private val mongoMastodonNotificationRepository: MongoMastodonNotificationRepository, + private val mongoTemplate: MongoTemplate +) : + MastodonNotificationRepository { + override suspend fun save(mastodonNotification: MastodonNotification): MastodonNotification = + mongoMastodonNotificationRepository.save(mastodonNotification) + + override suspend fun deleteById(id: Long) = mongoMastodonNotificationRepository.deleteById(id) + + override suspend fun findById(id: Long): MastodonNotification? = + mongoMastodonNotificationRepository.findById(id).getOrNull() + + override suspend fun findByUserIdAndMaxIdAndMinIdAndSinceIdAndInTypesAndInSourceActorId( + loginUser: Long, + maxId: Long?, + minId: Long?, + sinceId: Long?, + limit: Int, + typesTmp: MutableList, + accountId: List + ): List { + val query = Query() + + if (maxId != null) { + val criteria = Criteria.where("id").lte(maxId) + query.addCriteria(criteria) + } + + if (minId != null) { + val criteria = Criteria.where("id").gte(minId) + query.addCriteria(criteria) + } + if (sinceId != null) { + val criteria = Criteria.where("id").gte(sinceId) + query.addCriteria(criteria) + } + + query.limit(limit) + query.with(Sort.by(Sort.Direction.DESC, "createdAt")) + + return mongoTemplate.find(query, MastodonNotification::class.java) + } + + override suspend fun deleteByUserId(userId: Long) { + mongoMastodonNotificationRepository.deleteByUserId(userId) + } + + override suspend fun deleteByUserIdAndId(userId: Long, id: Long) { + mongoMastodonNotificationRepository.deleteByIdAndUserId(id, userId) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/notification/MastodonNotificationApiController.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/notification/MastodonNotificationApiController.kt new file mode 100644 index 00000000..ba83cb37 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/notification/MastodonNotificationApiController.kt @@ -0,0 +1,56 @@ +package dev.usbharu.hideout.mastodon.interfaces.api.notification + +import dev.usbharu.hideout.controller.mastodon.generated.NotificationsApi +import dev.usbharu.hideout.core.infrastructure.springframework.security.LoginUserContextHolder +import dev.usbharu.hideout.domain.mastodon.model.generated.Notification +import dev.usbharu.hideout.mastodon.domain.model.NotificationType +import dev.usbharu.hideout.mastodon.service.notification.NotificationApiService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.runBlocking +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller + +@Controller +class MastodonNotificationApiController( + private val loginUserContextHolder: LoginUserContextHolder, + private val notificationApiService: NotificationApiService +) : NotificationsApi { + override suspend fun apiV1NotificationsClearPost(): ResponseEntity { + notificationApiService.clearAll(loginUserContextHolder.getLoginUserId()) + return ResponseEntity.ok(null) + } + + override fun apiV1NotificationsGet( + maxId: String?, + sinceId: String?, + minId: String?, + limit: Int?, + types: List?, + excludeTypes: List?, + accountId: List? + ): ResponseEntity> = runBlocking { + val notificationFlow = notificationApiService.notifications( + loginUser = loginUserContextHolder.getLoginUserId(), + maxId = maxId?.toLong(), + minId = minId?.toLong(), + sinceId = sinceId?.toLong(), + limit = limit ?: 20, + types = types.orEmpty().mapNotNull { NotificationType.parse(it) }, + excludeTypes = excludeTypes.orEmpty().mapNotNull { NotificationType.parse(it) }, + accountId = accountId.orEmpty().mapNotNull { it.toLongOrNull() } + ).asFlow() + ResponseEntity.ok(notificationFlow) + } + + override suspend fun apiV1NotificationsIdDismissPost(id: String): ResponseEntity { + notificationApiService.dismiss(loginUserContextHolder.getLoginUserId(), id.toLong()) + return ResponseEntity.ok(null) + } + + override suspend fun apiV1NotificationsIdGet(id: String): ResponseEntity { + val notification = notificationApiService.fingById(loginUserContextHolder.getLoginUserId(), id.toLong()) + + return ResponseEntity.ok(notification) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/MastodonNotificationStore.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/MastodonNotificationStore.kt new file mode 100644 index 00000000..68cc449c --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/MastodonNotificationStore.kt @@ -0,0 +1,66 @@ +package dev.usbharu.hideout.mastodon.service.notification + +import dev.usbharu.hideout.core.domain.model.actor.Actor +import dev.usbharu.hideout.core.domain.model.notification.Notification +import dev.usbharu.hideout.core.domain.model.post.Post +import dev.usbharu.hideout.core.domain.model.reaction.Reaction +import dev.usbharu.hideout.core.service.notification.NotificationStore +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotification +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotificationRepository +import dev.usbharu.hideout.mastodon.domain.model.NotificationType +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class MastodonNotificationStore(private val mastodonNotificationRepository: MastodonNotificationRepository) : + NotificationStore { + override suspend fun publishNotification( + notification: Notification, + user: Actor, + sourceActor: Actor?, + post: Post?, + reaction: Reaction? + ): Boolean { + val notificationType = when (notification.type) { + "mention" -> NotificationType.mention + "post" -> NotificationType.status + "repost" -> NotificationType.reblog + "follow" -> NotificationType.follow + "follow-request" -> NotificationType.follow_request + "reaction" -> NotificationType.favourite + else -> { + logger.debug("Notification type does not support. type: {}", notification.type) + return false + } + } + + if (notification.sourceActorId == null) { + logger.debug("Notification does not support. notification.sourceActorId is null") + return false + } + + val mastodonNotification = MastodonNotification( + id = notification.id, + userId = notification.userId, + type = notificationType, + createdAt = notification.createdAt, + accountId = notification.sourceActorId, + statusId = notification.postId, + reportId = null, + relationshipServeranceEvent = null + ) + + mastodonNotificationRepository.save(mastodonNotification) + + return true + } + + override suspend fun unpulishNotification(notificationId: Long): Boolean { + mastodonNotificationRepository.deleteById(notificationId) + return true + } + + companion object { + private val logger = LoggerFactory.getLogger(MastodonNotificationStore::class.java) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/NotificationApiService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/NotificationApiService.kt new file mode 100644 index 00000000..0849d69a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/NotificationApiService.kt @@ -0,0 +1,24 @@ +package dev.usbharu.hideout.mastodon.service.notification + +import dev.usbharu.hideout.domain.mastodon.model.generated.Notification +import dev.usbharu.hideout.mastodon.domain.model.NotificationType + +interface NotificationApiService { + @Suppress("LongParameterList") + suspend fun notifications( + loginUser: Long, + maxId: Long?, + minId: Long?, + sinceId: Long?, + limit: Int, + types: List, + excludeTypes: List, + accountId: List + ): List + + suspend fun fingById(loginUser: Long, notificationId: Long): Notification? + + suspend fun clearAll(loginUser: Long) + + suspend fun dismiss(loginUser: Long, notificationId: Long) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/NotificationApiServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/NotificationApiServiceImpl.kt new file mode 100644 index 00000000..01b1bfb1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/notification/NotificationApiServiceImpl.kt @@ -0,0 +1,111 @@ +package dev.usbharu.hideout.mastodon.service.notification + +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.domain.mastodon.model.generated.Notification +import dev.usbharu.hideout.mastodon.domain.model.MastodonNotificationRepository +import dev.usbharu.hideout.mastodon.domain.model.NotificationType +import dev.usbharu.hideout.mastodon.domain.model.NotificationType.* +import dev.usbharu.hideout.mastodon.query.StatusQueryService +import dev.usbharu.hideout.mastodon.service.account.AccountService +import org.springframework.stereotype.Service + +@Service +class NotificationApiServiceImpl( + private val mastodonNotificationRepository: MastodonNotificationRepository, + private val transaction: Transaction, + private val accountService: AccountService, + private val statusQueryService: StatusQueryService +) : + NotificationApiService { + override suspend fun notifications( + loginUser: Long, + maxId: Long?, + minId: Long?, + sinceId: Long?, + limit: Int, + types: List, + excludeTypes: List, + accountId: List + ): List = transaction.transaction { + val typesTmp = mutableListOf() + + typesTmp.addAll(types) + typesTmp.removeAll(excludeTypes) + + val mastodonNotifications = + mastodonNotificationRepository.findByUserIdAndMaxIdAndMinIdAndSinceIdAndInTypesAndInSourceActorId( + loginUser, + maxId, + minId, + sinceId, + limit, + typesTmp, + accountId + ) + + val accounts = accountService.findByIds( + mastodonNotifications.map { + it.accountId + } + ).associateBy { it.id.toLong() } + + val statuses = statusQueryService.findByPostIds(mastodonNotifications.mapNotNull { it.statusId }) + .associateBy { it.id.toLong() } + + mastodonNotifications.map { + Notification( + id = it.id.toString(), + type = convertNotificationType(it.type), + createdAt = it.createdAt.toString(), + account = accounts.getValue(it.accountId), + status = statuses[it.statusId], + report = null, + relationshipSeveranceEvent = null + ) + } + } + + override suspend fun fingById(loginUser: Long, notificationId: Long): Notification? { + val findById = mastodonNotificationRepository.findById(notificationId) ?: return null + + if (findById.userId != loginUser) { + return null + } + + val account = accountService.findById(findById.accountId) + val status = findById.statusId?.let { statusQueryService.findByPostId(it) } + + return Notification( + id = findById.id.toString(), + type = convertNotificationType(findById.type), + createdAt = findById.createdAt.toString(), + account = account, + status = status, + report = null, + relationshipSeveranceEvent = null + ) + } + + override suspend fun clearAll(loginUser: Long) { + mastodonNotificationRepository.deleteByUserId(loginUser) + } + + override suspend fun dismiss(loginUser: Long, notificationId: Long) { + mastodonNotificationRepository.deleteByUserIdAndId(loginUser, notificationId) + } + + private fun convertNotificationType(notificationType: NotificationType): Notification.Type = + when (notificationType) { + mention -> Notification.Type.mention + status -> Notification.Type.status + reblog -> Notification.Type.reblog + follow -> Notification.Type.follow + follow_request -> Notification.Type.follow + favourite -> Notification.Type.followRequest + poll -> Notification.Type.poll + update -> Notification.Type.update + admin_sign_up -> Notification.Type.adminPeriodSignUp + admin_report -> Notification.Type.adminPeriodReport + severed_relationships -> Notification.Type.severedRelationships + } +} diff --git a/src/main/resources/db/migration/V1__Init_DB.sql b/src/main/resources/db/migration/V1__Init_DB.sql index fe745aed..286568f6 100644 --- a/src/main/resources/db/migration/V1__Init_DB.sql +++ b/src/main/resources/db/migration/V1__Init_DB.sql @@ -90,7 +90,7 @@ create table if not exists posts id bigint primary key, actor_id bigint not null, overview varchar(100) null, - content varchar(5000) not null, + content varchar(5000) not null, text varchar(3000) not null, created_at bigint not null, visibility int default 0 not null, @@ -253,4 +253,20 @@ create table if not exists deleted_actors public_key varchar(10000) not null, deleted_at timestamp not null, unique ("name", domain) +); + +create table if not exists notifications +( + id bigint primary key, + type varchar(100) not null, + user_id bigint not null, + source_actor_id bigint null, + post_id bigint null, + text varchar(3000) null, + reaction_id bigint null, + created_at timestamp not null, + constraint fk_notifications_user_id__id foreign key (user_id) references actors (id) on delete cascade on update cascade, + constraint fk_notifications_source_actor__id foreign key (source_actor_id) references actors (id) on delete cascade on update cascade, + constraint fk_notifications_post_id__id foreign key (post_id) references posts (id) on delete cascade on update cascade, + constraint fk_notifications_reaction_id__id foreign key (reaction_id) references reactions (id) on delete cascade on update cascade ) diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index 829da25c..c628586e 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -19,6 +19,8 @@ tags: description: timeline - name: media description: media + - name: notification + description: notification paths: /api/v2/instance: @@ -803,6 +805,122 @@ paths: schema: $ref: "#/components/schemas/Relationship" + /api/v1/notifications: + get: + tags: + - notifications + security: + - OAuth2: + - "read:notifications" + parameters: + - 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 + - in: query + name: types[] + required: false + schema: + type: array + items: + type: string + - in: query + name: exclude_types[] + required: false + schema: + type: array + items: + type: string + - in: query + name: account_id + required: false + schema: + type: array + items: + type: string + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Notification" + + /api/v1/notifications/{id}: + get: + tags: + - notifications + security: + - OAuth2: + - "read:notifications" + parameters: + - in: path + required: true + name: id + schema: + type: string + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Notification" + + /api/v1/notifications/clear: + post: + tags: + - notifications + security: + - OAuth2: + - "write:notifications" + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: object + + /api/v1/notifications/{id}/dismiss: + post: + tags: + - notifications + security: + - OAuth2: + - "write:notifications" + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: object + components: schemas: V1MediaRequest: @@ -1935,6 +2053,47 @@ components: value: type: string + Report: + type: object + + RelationshipServeranceEvent: + type: object + + Notification: + type: object + properties: + id: + type: string + type: + type: string + enum: + - mention + - status + - reblog + - follow + - follow_request + - favourite + - poll + - update + - admin.sign_up + - admin.report + - severed_relationships + created_at: + type: string + account: + $ref: "#/components/schemas/Account" + status: + $ref: "#/components/schemas/Status" + report: + $ref: "#/components/schemas/Report" + relationship_severance_event: + $ref: "#/components/schemas/RelationshipServeranceEvent" + required: + - id + - type + - created_at + - account + securitySchemes: OAuth2: type: oauth2 diff --git a/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt b/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt index 7d2454c1..1a339cc3 100644 --- a/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt @@ -96,6 +96,9 @@ class EqualsAndToStringTest { .filter { it.superclass == Any::class.java || it.superclass?.packageName?.startsWith("dev.usbharu") ?: true } + .filterNot { + it.superclass.isSealed + } .filterNot { it == UnicodeEmoji::class.java } .map { diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/FollowNotificationRequestTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/FollowNotificationRequestTest.kt new file mode 100644 index 00000000..1e886b32 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/FollowNotificationRequestTest.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.time.Instant + +class FollowNotificationRequestTest { + + @Test + fun buildNotification() { + val createdAt = Instant.now() + val actual = FollowNotificationRequest(1, 2).buildNotification(1, createdAt) + + Assertions.assertThat(actual).isEqualTo( + Notification( + id = 1, + type = "follow", + userId = 1, + sourceActorId = 2, + postId = null, + text = null, + reactionId = null, + createdAt = createdAt + ) + ) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/FollowRequestNotificationRequestTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/FollowRequestNotificationRequestTest.kt new file mode 100644 index 00000000..370e24be --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/FollowRequestNotificationRequestTest.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.time.Instant + +class FollowRequestNotificationRequestTest { + + @Test + fun buildNotification() { + val createdAt = Instant.now() + val actual = FollowRequestNotificationRequest(1, 2).buildNotification(1, createdAt) + + Assertions.assertThat(actual).isEqualTo( + Notification( + id = 1, + type = "follow-request", + userId = 1, + sourceActorId = 2, + postId = null, + text = null, + reactionId = null, + createdAt = createdAt + ) + ) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/MentionNotificationRequestTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/MentionNotificationRequestTest.kt new file mode 100644 index 00000000..05172802 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/MentionNotificationRequestTest.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant + +class MentionNotificationRequestTest { + + @Test + fun buildNotification() { + val createdAt = Instant.now() + val actual = MentionNotificationRequest(1, 2, 3).buildNotification(1, createdAt) + + assertThat(actual).isEqualTo( + Notification( + id = 1, + type = "mention", + userId = 1, + sourceActorId = 2, + postId = 3, + text = null, + reactionId = null, + createdAt = createdAt + ) + ) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/NotificationServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/NotificationServiceImplTest.kt new file mode 100644 index 00000000..7ac0bacd --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/NotificationServiceImplTest.kt @@ -0,0 +1,97 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.application.config.ApplicationConfig +import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService +import dev.usbharu.hideout.core.domain.model.actor.ActorRepository +import dev.usbharu.hideout.core.domain.model.notification.Notification +import dev.usbharu.hideout.core.domain.model.notification.NotificationRepository +import dev.usbharu.hideout.core.domain.model.post.PostRepository +import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository +import dev.usbharu.hideout.core.domain.model.relationship.RelationshipRepository +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.UserBuilder +import java.net.URL + +@ExtendWith(MockitoExtension::class) +class NotificationServiceImplTest { + + + @Mock + private lateinit var relationshipNotificationManagementService: RelationshipNotificationManagementService + + @Mock + private lateinit var relationshipRepository: RelationshipRepository + + @Spy + private val notificationStoreList: MutableList = mutableListOf() + + @Mock + private lateinit var notificationRepository: NotificationRepository + + @Mock + private lateinit var actorRepository: ActorRepository + + @Mock + private lateinit var postRepository: PostRepository + + @Mock + private lateinit var reactionRepository: ReactionRepository + + @Spy + private val applicationConfig = ApplicationConfig(URL("https://example.com")) + + @InjectMocks + private lateinit var notificationServiceImpl: NotificationServiceImpl + + @Test + fun `publishNotifi ローカルユーザーへの通知を発行する`() = runTest { + + val actor = UserBuilder.localUserOf(domain = "example.com") + + whenever(actorRepository.findById(eq(1))).doReturn(actor) + + val id = TwitterSnowflakeIdGenerateService.generateId() + + whenever(notificationRepository.generateId()).doReturn(id) + + whenever(notificationRepository.save(any())).doAnswer { it.arguments[0] as Notification } + + + val actual = notificationServiceImpl.publishNotify(PostNotificationRequest(1, 2, 3)) + + assertThat(actual).isNotNull() + + verify(notificationRepository, times(1)).save(any()) + } + + @Test + fun `publishNotify ユーザーが存在しないときは発行しない`() = runTest { + val actual = notificationServiceImpl.publishNotify(PostNotificationRequest(1, 2, 3)) + + assertThat(actual).isNull() + } + + @Test + fun `publishNotify ユーザーがリモートユーザーの場合は発行しない`() = runTest { + val actor = UserBuilder.remoteUserOf(domain = "remote.example.com") + + whenever(actorRepository.findById(eq(1))).doReturn(actor) + + val actual = notificationServiceImpl.publishNotify(PostNotificationRequest(1, 2, 3)) + + assertThat(actual).isNull() + } + + @Test + fun unpublishNotify() = runTest { + notificationServiceImpl.unpublishNotify(1) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/PostNotificationRequestTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/PostNotificationRequestTest.kt new file mode 100644 index 00000000..d4ce9bb7 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/PostNotificationRequestTest.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.time.Instant + +class PostNotificationRequestTest { + + @Test + fun buildNotification() { + val createdAt = Instant.now() + val actual = PostNotificationRequest(1, 2, 3).buildNotification(1, createdAt) + + Assertions.assertThat(actual).isEqualTo( + Notification( + id = 1, + type = "post", + userId = 1, + sourceActorId = 2, + postId = 3, + text = null, + reactionId = null, + createdAt = createdAt + ) + ) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/ReactionNotificationRequestTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/ReactionNotificationRequestTest.kt new file mode 100644 index 00000000..1d662bc8 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/ReactionNotificationRequestTest.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.time.Instant + +class ReactionNotificationRequestTest { + + @Test + fun buildNotification() { + val createdAt = Instant.now() + val actual = ReactionNotificationRequest(1, 2, 3, 4).buildNotification(1, createdAt) + + Assertions.assertThat(actual).isEqualTo( + Notification( + id = 1, + type = "reaction", + userId = 1, + sourceActorId = 2, + postId = 3, + text = null, + reactionId = 4, + createdAt = createdAt + ) + ) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementServiceImplTest.kt new file mode 100644 index 00000000..0293920a --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/RelationshipNotificationManagementServiceImplTest.kt @@ -0,0 +1,25 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.relationship.Relationship +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class RelationshipNotificationManagementServiceImplTest { + @Test + fun `sendNotification ミューとしていない場合送信する`() { + val notification = RelationshipNotificationManagementServiceImpl().sendNotification( + Relationship( + 1, + 2, + false, + false, + false, + false, + false + ), PostNotificationRequest(1, 2, 3) + ) + + assertTrue(notification) + + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/notification/RepostNotificationRequestTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/RepostNotificationRequestTest.kt new file mode 100644 index 00000000..f81f8786 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/notification/RepostNotificationRequestTest.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.service.notification + +import dev.usbharu.hideout.core.domain.model.notification.Notification +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.time.Instant + +class RepostNotificationRequestTest { + + @Test + fun buildNotification() { + val createdAt = Instant.now() + val actual = RepostNotificationRequest(1, 2, 3).buildNotification(1, createdAt) + + Assertions.assertThat(actual).isEqualTo( + Notification( + id = 1, + type = "repost", + userId = 1, + sourceActorId = 2, + postId = 3, + text = null, + reactionId = null, + createdAt = createdAt + ) + ) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt index f3863d64..98558744 100644 --- a/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt @@ -4,8 +4,10 @@ package dev.usbharu.hideout.core.service.reaction import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji +import dev.usbharu.hideout.core.domain.model.post.PostRepository import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository +import dev.usbharu.hideout.core.service.notification.NotificationService import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -18,6 +20,12 @@ import utils.PostBuilder @ExtendWith(MockitoExtension::class) class ReactionServiceImplTest { + @Mock + private lateinit var notificationService: NotificationService + + @Mock + private lateinit var postRepository: PostRepository + @Mock private lateinit var reactionRepository: ReactionRepository @@ -35,6 +43,9 @@ class ReactionServiceImplTest { whenever(reactionRepository.existByPostIdAndActor(eq(post.id), eq(post.actorId))).doReturn( false ) + whenever(postRepository.findById(eq(post.id))).doReturn(post) + whenever(reactionRepository.save(any())).doAnswer { it.arguments[0] as Reaction } + val generateId = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.generateId()).doReturn(generateId) @@ -50,7 +61,8 @@ class ReactionServiceImplTest { whenever(reactionRepository.existByPostIdAndActor(eq(post.id), eq(post.actorId))).doReturn( true ) - + whenever(postRepository.findById(eq(post.id))).doReturn(post) + whenever(reactionRepository.save(any())).doAnswer { it.arguments[0] as Reaction } val generateId = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.generateId()).doReturn(generateId) @@ -67,6 +79,8 @@ class ReactionServiceImplTest { whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( null ) + whenever(postRepository.findById(eq(post.id))).doReturn(post) + whenever(reactionRepository.save(any())).doAnswer { it.arguments[0] as Reaction } val generateId = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.generateId()).doReturn(generateId) @@ -83,6 +97,8 @@ class ReactionServiceImplTest { whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( Reaction(id, UnicodeEmoji("❤"), post.id, post.actorId) ) + whenever(postRepository.findById(eq(post.id))).doReturn(post) + whenever(reactionRepository.save(any())).doAnswer { it.arguments[0] as Reaction } val generateId = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.generateId()).doReturn(generateId) diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImplTest.kt index 44e43a17..cd338e15 100644 --- a/src/test/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/relationship/RelationshipServiceImplTest.kt @@ -10,6 +10,7 @@ import dev.usbharu.hideout.core.domain.model.actor.ActorRepository import dev.usbharu.hideout.core.domain.model.relationship.Relationship import dev.usbharu.hideout.core.domain.model.relationship.RelationshipRepository import dev.usbharu.hideout.core.service.follow.SendFollowDto +import dev.usbharu.hideout.core.service.notification.NotificationService import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -25,6 +26,10 @@ import java.net.URL @ExtendWith(MockitoExtension::class) class RelationshipServiceImplTest { + + @Mock + private lateinit var notificationService: NotificationService + @Spy private val applicationConfig = ApplicationConfig(URL("https://example.com")) diff --git a/src/test/kotlin/dev/usbharu/hideout/mastodon/domain/model/NotificationTypeTest.kt b/src/test/kotlin/dev/usbharu/hideout/mastodon/domain/model/NotificationTypeTest.kt new file mode 100644 index 00000000..87244005 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/mastodon/domain/model/NotificationTypeTest.kt @@ -0,0 +1,43 @@ +package dev.usbharu.hideout.mastodon.domain.model + +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import java.util.stream.Stream +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NotificationTypeTest { + @ParameterizedTest + @MethodSource("parseSuccessProvider") + fun parseに成功する(s: String, notificationType: NotificationType) { + assertEquals(notificationType, NotificationType.parse(s)) + } + + @ParameterizedTest + @ValueSource(strings = ["hoge", "fuga", "0x1234", "follow_reject", "test", "mentiooon", "emoji_reaction", "reaction"]) + fun parseに失敗する(s: String) { + assertNull(NotificationType.parse(s)) + } + + companion object { + @JvmStatic + fun parseSuccessProvider(): Stream { + return Stream.of( + arguments("mention", NotificationType.mention), + arguments("status", NotificationType.status), + arguments("reblog", NotificationType.reblog), + arguments("follow", NotificationType.follow), + arguments("follow_request", NotificationType.follow_request), + arguments("favourite", NotificationType.favourite), + arguments("poll", NotificationType.poll), + arguments("update", NotificationType.update), + arguments("admin.sign_up", NotificationType.admin_sign_up), + arguments("admin.report", NotificationType.admin_report), + arguments("servered_relationships", NotificationType.severed_relationships) + ) + } + } +}