Merge pull request #259 from usbharu/feature/notification

Feature/notification
This commit is contained in:
usbharu 2024-01-28 17:54:26 +09:00 committed by GitHub
commit fe3082db59
43 changed files with 1493 additions and 25 deletions

View File

@ -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})"
}

View File

@ -272,7 +272,9 @@ class SecurityConfig {
fun jwtTokenCustomizer(): OAuth2TokenCustomizer<JwtEncodingContext> {
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<Authentication>().principal as UserDetailsImpl
context.claims.claim("uid", userDetailsImpl.id.toString())
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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
)

View File

@ -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)
}

View File

@ -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<Reaction>
suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction?
suspend fun existByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Boolean

View File

@ -43,6 +43,7 @@ interface RelationshipRepository {
ignoreFollowRequest: Boolean
): List<Relationship>
@Suppress("FunctionMaxLength")
suspend fun findByActorIdAntMutingAndMaxIdAndSinceId(
actorId: Long,
muting: Boolean,

View File

@ -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<Reaction> = query {
return@query Reactions.leftJoin(CustomEmojis).select { Reactions.postId eq postId }.map { it.toReaction() }
}

View File

@ -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
)
}

View File

@ -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)
}

View File

@ -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<NotificationStore>,
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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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 {
""
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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) {

View File

@ -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?
)

View File

@ -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<NotificationType>,
accountId: List<Long>
): List<MastodonNotification>
suspend fun deleteByUserId(userId: Long)
suspend fun deleteByUserIdAndId(userId: Long, id: Long)
}

View File

@ -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
}
}
}

View File

@ -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<NotificationType>,
accountId: List<Long>
): List<MastodonNotification> = 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()
}

View File

@ -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<MastodonNotification, Long> {
fun deleteByUserId(userId: Long): Long
fun deleteByIdAndUserId(id: Long, userId: Long): Long
}

View File

@ -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<NotificationType>,
accountId: List<Long>
): List<MastodonNotification> {
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)
}
}

View File

@ -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<Any> {
notificationApiService.clearAll(loginUserContextHolder.getLoginUserId())
return ResponseEntity.ok(null)
}
override fun apiV1NotificationsGet(
maxId: String?,
sinceId: String?,
minId: String?,
limit: Int?,
types: List<String>?,
excludeTypes: List<String>?,
accountId: List<String>?
): ResponseEntity<Flow<Notification>> = 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<Any> {
notificationApiService.dismiss(loginUserContextHolder.getLoginUserId(), id.toLong())
return ResponseEntity.ok(null)
}
override suspend fun apiV1NotificationsIdGet(id: String): ResponseEntity<Notification> {
val notification = notificationApiService.fingById(loginUserContextHolder.getLoginUserId(), id.toLong())
return ResponseEntity.ok(notification)
}
}

View File

@ -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)
}
}

View File

@ -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<NotificationType>,
excludeTypes: List<NotificationType>,
accountId: List<Long>
): List<Notification>
suspend fun fingById(loginUser: Long, notificationId: Long): Notification?
suspend fun clearAll(loginUser: Long)
suspend fun dismiss(loginUser: Long, notificationId: Long)
}

View File

@ -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<NotificationType>,
excludeTypes: List<NotificationType>,
accountId: List<Long>
): List<Notification> = transaction.transaction {
val typesTmp = mutableListOf<NotificationType>()
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
}
}

View File

@ -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
)

View File

@ -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

View File

@ -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 {

View File

@ -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
)
)
}
}

View File

@ -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
)
)
}
}

View File

@ -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
)
)
}
}

View File

@ -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<NotificationStore> = 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)
}
}

View File

@ -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
)
)
}
}

View File

@ -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
)
)
}
}

View File

@ -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)
}
}

View File

@ -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
)
)
}
}

View File

@ -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)

View File

@ -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"))

View File

@ -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<Arguments> {
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)
)
}
}
}