Merge pull request #276 from usbharu/feature/mute

ミュートを追加
This commit is contained in:
usbharu 2024-02-16 11:37:03 +09:00 committed by GitHub
commit 31075240a0
32 changed files with 2904 additions and 57 deletions

View File

@ -0,0 +1,694 @@
package mastodon.filter
import dev.usbharu.hideout.SpringApplication
import dev.usbharu.hideout.application.config.ActivityPubConfig
import dev.usbharu.hideout.domain.mastodon.model.generated.FilterKeywordsPostRequest
import dev.usbharu.hideout.domain.mastodon.model.generated.FilterPostRequest
import dev.usbharu.hideout.domain.mastodon.model.generated.FilterPostRequestKeyword
import dev.usbharu.hideout.domain.mastodon.model.generated.V1FilterPostRequest
import kotlinx.coroutines.test.runTest
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext
@SpringBootTest(classes = [SpringApplication::class])
@AutoConfigureMockMvc
@Transactional
@Sql("/sql/test-user.sql", "/sql/filter/test-filter.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
class FilterTest {
@Autowired
private lateinit var context: WebApplicationContext
private lateinit var mockMvc: MockMvc
@BeforeEach
fun setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(SecurityMockMvcConfigurers.springSecurity())
.build()
}
@Test
fun `apiV2FiltersPost write権限で追加できる`() {
mockMvc
.post("/api/v2/filters") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
FilterPostRequest(
title = "mute test",
context = listOf(FilterPostRequest.Context.home, FilterPostRequest.Context.public),
filterAction = FilterPostRequest.FilterAction.warn,
expiresIn = null,
keywordsAttributes = listOf(
FilterPostRequestKeyword(
keyword = "hoge",
wholeWord = false,
regex = true
)
)
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
.andExpect {
content {
jsonPath("$.keywords[0].keyword") {
value("hoge")
}
}
}
}
@Test
fun `apiV2FiltersPost write_filters権限で追加できる`() {
mockMvc
.post("/api/v2/filters") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
FilterPostRequest(
title = "mute test",
context = listOf(FilterPostRequest.Context.home, FilterPostRequest.Context.public),
filterAction = FilterPostRequest.FilterAction.warn,
expiresIn = null,
keywordsAttributes = listOf(
FilterPostRequestKeyword(
keyword = "fuga",
wholeWord = true,
regex = false
)
)
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
.andExpect {
content {
jsonPath("$.keywords[0].keyword") {
value("fuga")
}
}
}
}
@Test
fun `apiV2FiltersPost read権限で401`() {
mockMvc
.post("/api/v2/filters") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
FilterPostRequest(
title = "mute test",
context = listOf(FilterPostRequest.Context.home, FilterPostRequest.Context.public),
filterAction = FilterPostRequest.FilterAction.warn,
expiresIn = null,
keywordsAttributes = listOf(
FilterPostRequestKeyword(
keyword = "fuga",
wholeWord = true,
regex = false
)
)
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersGet read権限で取得できる`() {
mockMvc
.get("/api/v2/filters") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersGet read_filters権限で取得できる`() {
mockMvc
.get("/api/v2/filters") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersGet write権限で401`() {
mockMvc
.get("/api/v2/filters") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersIdGet read権限で取得できる`() {
mockMvc
.get("/api/v2/filters/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersIdGet read_filters権限で取得できる`() {
mockMvc
.get("/api/v2/filters/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersIdGet write権限で401`() {
mockMvc
.get("/api/v2/filters/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersFilterIdKeywordsGet read権限で取得できる`() {
mockMvc
.get("/api/v2/filters/1/keywords") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersFilterIdKeywordsGet read_filters権限で取得できる`() {
mockMvc
.get("/api/v2/filters/1/keywords") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersFilterIdKeywordsGet writeで403`() {
mockMvc
.get("/api/v2/filters/1/keywords") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersFilterIdKeywordsPost writeで追加できる`() {
mockMvc
.post("/api/v2/filters/1/keywords") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
FilterKeywordsPostRequest(
"hage", false, false
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersFilterIdKeywordsPost write_filtersで追加できる`() {
mockMvc
.post("/api/v2/filters/1/keywords") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
FilterKeywordsPostRequest(
"hage", false, false
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersFilterIdKeywordsPost readで403`() {
mockMvc
.post("/api/v2/filters/1/keywords") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
FilterKeywordsPostRequest(
"hage", false, false
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersKeywordsIdGet readで取得できる`() {
mockMvc
.get("/api/v2/filters/keywords/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersKeywordsIdGet read_filtersで取得できる`() {
mockMvc
.get("/api/v2/filters/keywords/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersKeywordsIdGet writeだと403`() {
mockMvc
.get("/api/v2/filters/keywords/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersKeyowrdsIdDelete writeで削除できる`() = runTest {
mockMvc
.delete("/api/v2/filters/keywords/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersKeyowrdsIdDelete write_filtersで削除できる`() = runTest {
mockMvc
.delete("/api/v2/filters/keywords/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersKeyowrdsIdDelete readで403`() = runTest {
mockMvc
.delete("/api/v2/filters/keywords/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersFilterIdStatuses readで取得できる`() {
mockMvc
.get("/api/v2/filters/1/statuses") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersFilterIdStatuses read_filtersで取得できる`() {
mockMvc
.get("/api/v2/filters/1/statuses") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersFilterIdStatuses writeで403`() {
mockMvc
.get("/api/v2/filters/1/statuses") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersStatusesIdGet readで取得できる`() {
mockMvc
.get("/api/v2/filters/statuses/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersStatusesIdGet read_filtersで取得できる`() {
mockMvc
.get("/api/v2/filters/statuses/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersStatusesIdGet writeで403`() {
mockMvc
.get("/api/v2/filters/statuses/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV2FiltersStatusesIdDelete writeで削除できる`() {
mockMvc
.delete("/api/v2/filters/statuses/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersStatusesIdDelete write_filtersで削除できる`() {
mockMvc
.delete("/api/v2/filters/statuses/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV2FiltersStatusesIdDelete readで403`() {
mockMvc
.delete("/api/v2/filters/statuses/1") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV1FiltersGet readで取得できる`() {
mockMvc
.get("/api/v1/filters") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersGet read_filtersで取得できる`() {
mockMvc
.get("/api/v1/filters") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersGet writeで403`() {
mockMvc
.get("/api/v1/filters") {
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV1FiltersPost writeで新規作成`() {
mockMvc
.post("/api/v1/filters") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
V1FilterPostRequest(
phrase = "hoge",
context = listOf(V1FilterPostRequest.Context.home),
irreversible = false,
wholeWord = false,
expiresIn = null
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersPost write_filtersで新規作成`() {
mockMvc
.post("/api/v1/filters") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
V1FilterPostRequest(
phrase = "hoge",
context = listOf(V1FilterPostRequest.Context.home),
irreversible = false,
wholeWord = false,
expiresIn = null
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersPost readで403`() {
mockMvc
.post("/api/v1/filters") {
contentType = MediaType.APPLICATION_JSON
content = ActivityPubConfig().objectMapper().writeValueAsString(
V1FilterPostRequest(
phrase = "hoge",
context = listOf(V1FilterPostRequest.Context.home),
irreversible = false,
wholeWord = false,
expiresIn = null
)
)
with(
jwt()
.jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV1FiltersIdGet readで取得できる`() {
mockMvc
.get("/api/v1/filters/1") {
with(
jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersIdGet read_filtersで取得できる`() {
mockMvc
.get("/api/v1/filters/1") {
with(
jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersIdGet writeで403`() {
mockMvc
.get("/api/v1/filters/1") {
with(
jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV1FiltersIdDelete writeで削除できる`() {
mockMvc
.delete("/api/v1/filters/1") {
with(
jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersIdDelete write_filtersで削除できる`() {
mockMvc
.delete("/api/v1/filters/1") {
with(
jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:filters"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1FiltersIdDelete readで403`() {
mockMvc
.delete("/api/v1/filters/1") {
with(
jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))
)
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
companion object {
@JvmStatic
@AfterAll
fun dropDatabase(@Autowired flyway: Flyway) {
flyway.clean()
flyway.migrate()
}
}
}

View File

@ -1,4 +1,7 @@
hideout:
debug:
trace-query-exception: true
trace-query-call: true
url: "https://example.com"
use-mongodb: true
security:

View File

@ -0,0 +1,4 @@
insert into filters (id, user_id, name, context, action)
VALUES (1, 1, 'test filter', 'home', 'warn');
insert into filter_keywords(id, filter_id, keyword, mode)
VALUES (1, 1, 'hoge', 'NONE')

View File

@ -180,41 +180,51 @@ class APNoteServiceImpl(
}.map { it.id }
val createPost =
if (quote != null) {
postBuilder.quoteRepostOf(
id = postRepository.generateId(),
actorId = person.second.id,
content = note.content,
createdAt = Instant.parse(note.published),
visibility = visibility,
url = note.id,
replyId = reply?.id,
sensitive = note.sensitive,
apId = note.id,
mediaIds = mediaList,
emojiIds = emojis,
repost = quote
)
} else {
postBuilder.of(
id = postRepository.generateId(),
actorId = person.second.id,
content = note.content,
createdAt = Instant.parse(note.published).toEpochMilli(),
visibility = visibility,
url = note.id,
replyId = reply?.id,
sensitive = note.sensitive,
apId = note.id,
mediaIds = mediaList,
emojiIds = emojis
)
}
post(quote, person, note, visibility, reply, mediaList, emojis)
val createRemote = postService.createRemote(createPost)
return note to createRemote
}
private suspend fun post(
quote: Post?,
person: Pair<Person, Actor>,
note: Note,
visibility: Visibility,
reply: Post?,
mediaList: List<Long>,
emojis: List<Long>
) = if (quote != null) {
postBuilder.quoteRepostOf(
id = postRepository.generateId(),
actorId = person.second.id,
content = note.content,
createdAt = Instant.parse(note.published),
visibility = visibility,
url = note.id,
replyId = reply?.id,
sensitive = note.sensitive,
apId = note.id,
mediaIds = mediaList,
emojiIds = emojis,
repost = quote
)
} else {
postBuilder.of(
id = postRepository.generateId(),
actorId = person.second.id,
content = note.content,
createdAt = Instant.parse(note.published).toEpochMilli(),
visibility = visibility,
url = note.id,
replyId = reply?.id,
sensitive = note.sensitive,
apId = note.id,
mediaIds = mediaList,
emojiIds = emojis
)
}
private suspend fun buildEmojis(note: Note) = note.tag
.filterIsInstance<Emoji>()
.map {

View File

@ -26,8 +26,7 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.core.annotation.Order
import org.springframework.http.HttpMethod.GET
import org.springframework.http.HttpMethod.POST
import org.springframework.http.HttpMethod.*
import org.springframework.http.HttpStatus
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
@ -210,6 +209,33 @@ class SecurityConfig {
authorize(GET, "/api/v1/timelines/public", permitAll)
authorize(GET, "/api/v1/timelines/home", hasAnyScope("read", "read:statuses"))
authorize(GET, "/api/v2/filters", hasAnyScope("read", "read:filters"))
authorize(POST, "/api/v2/filters", hasAnyScope("write", "write:filters"))
authorize(GET, "/api/v2/filters/*", hasAnyScope("read", "read:filters"))
authorize(PUT, "/api/v2/filters/*", hasAnyScope("write", "write:filters"))
authorize(DELETE, "/api/v2/filters/*", hasAnyScope("write", "write:filters"))
authorize(GET, "/api/v2/filters/*/keywords", hasAnyScope("read", "read:filters"))
authorize(POST, "/api/v2/filters/*/keywords", hasAnyScope("write", "write:filters"))
authorize(GET, "/api/v2/filters/keywords/*", hasAnyScope("read", "read:filters"))
authorize(PUT, "/api/v2/filters/keywords/*", hasAnyScope("write", "write:filters"))
authorize(DELETE, "/api/v2/filters/keywords/*", hasAnyScope("write", "write:filters"))
authorize(GET, "/api/v2/filters/*/statuses", hasAnyScope("read", "read:filters"))
authorize(POST, "/api/v2/filters/*/statuses", hasAnyScope("write", "write:filters"))
authorize(GET, "/api/v2/filters/statuses/*", hasAnyScope("read", "read:filters"))
authorize(DELETE, "/api/v2/filters/statuses/*", hasAnyScope("write", "write:filters"))
authorize(GET, "/api/v1/filters", hasAnyScope("read", "read:filters"))
authorize(POST, "/api/v1/filters", hasAnyScope("write", "write:filters"))
authorize(GET, "/api/v1/filters/*", hasAnyScope("read", "read:filters"))
authorize(POST, "/api/v1/filters/*", hasAnyScope("write", "write:filters"))
authorize(DELETE, "/api/v1/filters/*", hasAnyScope("write", "write:filters"))
authorize(anyRequest, authenticated)
}

View File

@ -0,0 +1,9 @@
package dev.usbharu.hideout.core.domain.model.filter
data class Filter(
val id: Long,
val userId: Long,
val name: String,
val context: List<FilterType>,
val filterAction: FilterAction,
)

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.core.domain.model.filter
@Suppress("EnumEntryNameCase", "EnumNaming")
enum class FilterAction {
warn,
hide
}

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.core.domain.model.filter
enum class FilterMode {
WHOLE_WORD,
REGEX,
NONE
}

View File

@ -0,0 +1,14 @@
package dev.usbharu.hideout.core.domain.model.filter
interface FilterRepository {
suspend fun generateId(): Long
suspend fun save(filter: Filter): Filter
suspend fun findById(id: Long): Filter?
suspend fun findByUserIdAndId(userId: Long, id: Long): Filter?
suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<Filter>
suspend fun deleteById(id: Long)
suspend fun deleteByUserIdAndId(userId: Long, id: Long)
}

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.core.domain.model.filter
@Suppress("EnumEntryNameCase", "EnumNaming")
enum class FilterType {
home,
notifications,
public,
thread,
account
}

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.core.domain.model.filterkeyword
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
data class FilterKeyword(
val id: Long,
val filterId: Long,
val keyword: String,
val mode: FilterMode
)

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.core.domain.model.filterkeyword
interface FilterKeywordRepository {
suspend fun generateId(): Long
suspend fun save(filterKeyword: FilterKeyword): FilterKeyword
suspend fun saveAll(filterKeywordList: List<FilterKeyword>)
suspend fun findById(id: Long): FilterKeyword?
suspend fun deleteById(id: Long)
suspend fun deleteByFilterId(filterId: Long)
}

View File

@ -89,6 +89,7 @@ data class Post private constructor(
)
}
@Suppress("LongParameterList")
fun pureRepostOf(
id: Long,
actorId: Long,
@ -110,24 +111,25 @@ data class Post private constructor(
require(actorId >= 0) { "actorId must be greater than or equal to 0." }
return Post(
id,
actorId,
null,
"",
"",
createdAt.toEpochMilli(),
fixedVisibility,
url,
repost.id,
null,
false,
apId,
emptyList(),
false,
emptyList()
id = id,
actorId = actorId,
overview = null,
content = "",
text = "",
createdAt = createdAt.toEpochMilli(),
visibility = fixedVisibility,
url = url,
repostId = repost.id,
replyId = null,
sensitive = false,
apId = apId,
mediaIds = emptyList(),
delted = false,
emojiIds = emptyList()
)
}
@Suppress("LongParameterList")
fun quoteRepostOf(
id: Long,
actorId: Long,

View File

@ -0,0 +1,82 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeywordRepository
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Repository
@Repository
class ExposedFilterKeywordRepository(private val idGenerateService: IdGenerateService) : FilterKeywordRepository,
AbstractRepository() {
override val logger: Logger
get() = Companion.logger
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(filterKeyword: FilterKeyword): FilterKeyword = query {
val empty = FilterKeywords.selectAll().where { FilterKeywords.id eq filterKeyword.id }.empty()
if (empty) {
FilterKeywords.insert {
it[id] = filterKeyword.id
it[filterId] = filterKeyword.filterId
it[keyword] = filterKeyword.keyword
it[mode] = filterKeyword.mode.name
}
} else {
FilterKeywords.update({ FilterKeywords.id eq filterKeyword.id }) {
it[filterId] = filterKeyword.filterId
it[keyword] = filterKeyword.keyword
it[mode] = filterKeyword.mode.name
}
}
filterKeyword
}
override suspend fun saveAll(filterKeywordList: List<FilterKeyword>): Unit = query {
FilterKeywords.batchInsert(filterKeywordList, ignore = true) {
this[FilterKeywords.id] = it.id
this[FilterKeywords.filterId] = it.filterId
this[FilterKeywords.keyword] = it.keyword
this[FilterKeywords.mode] = it.mode.name
}
}
override suspend fun findById(id: Long): FilterKeyword? = query {
return@query FilterKeywords.selectAll().where { FilterKeywords.id eq id }.singleOrNull()?.toFilterKeyword()
}
override suspend fun deleteById(id: Long): Unit = query {
FilterKeywords.deleteWhere { FilterKeywords.id eq id }
}
override suspend fun deleteByFilterId(filterId: Long): Unit = query {
FilterKeywords.deleteWhere { FilterKeywords.filterId eq filterId }
}
companion object {
private val logger = LoggerFactory.getLogger(ExposedFilterKeywordRepository::class.java)
}
}
fun ResultRow.toFilterKeyword(): FilterKeyword {
return FilterKeyword(
this[FilterKeywords.id],
this[FilterKeywords.filterId],
this[FilterKeywords.keyword],
this[FilterKeywords.mode].let { FilterMode.valueOf(it) }
)
}
object FilterKeywords : Table("filter_keywords") {
val id = long("id")
val filterId = long("filter_id").references(Filters.id)
val keyword = varchar("keyword", 1000)
val mode = varchar("mode", 100)
override val primaryKey: PrimaryKey = PrimaryKey(id)
}

View File

@ -0,0 +1,88 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterRepository
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Repository
@Repository
class ExposedFilterRepository(private val idGenerateService: IdGenerateService) : FilterRepository,
AbstractRepository() {
override val logger: Logger
get() = Companion.logger
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(filter: Filter): Filter = query {
val empty = Filters.selectAll().where {
Filters.id eq filter.id
}.forUpdate().empty()
if (empty) {
Filters.insert {
it[id] = filter.id
it[userId] = filter.userId
it[name] = filter.name
it[context] = filter.context.joinToString(",") { filterType -> filterType.name }
it[filterAction] = filter.filterAction.name
}
} else {
Filters.update({ Filters.id eq filter.id }) {
it[userId] = filter.userId
it[name] = filter.name
it[context] = filter.context.joinToString(",") { filterType -> filterType.name }
it[filterAction] = filter.filterAction.name
}
}
filter
}
override suspend fun findById(id: Long): Filter? = query {
return@query Filters.selectAll().where { Filters.id eq id }.singleOrNull()?.toFilter()
}
override suspend fun findByUserIdAndId(userId: Long, id: Long): Filter? = query {
return@query Filters.selectAll().where { Filters.userId eq userId and (Filters.id eq id) }.singleOrNull()
?.toFilter()
}
override suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<Filter> = query {
return@query Filters.selectAll().where { Filters.userId eq userId }.map { it.toFilter() }
.filter { it.context.containsAll(types) }
}
override suspend fun deleteById(id: Long): Unit = query {
Filters.deleteWhere { Filters.id eq id }
}
override suspend fun deleteByUserIdAndId(userId: Long, id: Long) {
Filters.deleteWhere { Filters.userId eq userId and (Filters.id eq id) }
}
companion object {
private val logger = LoggerFactory.getLogger(ExposedFilterRepository::class.java)
}
}
fun ResultRow.toFilter(): Filter = Filter(
this[Filters.id],
this[Filters.userId],
this[Filters.name],
this[Filters.context].split(",").filterNot(String::isEmpty).map { FilterType.valueOf(it) },
this[Filters.filterAction].let { FilterAction.valueOf(it) }
)
object Filters : Table() {
val id = long("id")
val userId = long("user_id").references(Actors.id)
val name = varchar("name", 255)
val context = varchar("context", 500)
val filterAction = varchar("action", 255)
override val primaryKey: PrimaryKey = PrimaryKey(id)
}

View File

@ -0,0 +1,60 @@
package dev.usbharu.hideout.core.query.model
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.infrastructure.exposedrepository.FilterKeywords
import dev.usbharu.hideout.core.infrastructure.exposedrepository.Filters
import dev.usbharu.hideout.core.infrastructure.exposedrepository.toFilter
import dev.usbharu.hideout.core.infrastructure.exposedrepository.toFilterKeyword
import org.jetbrains.exposed.sql.Query
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
import org.springframework.stereotype.Repository
@Repository
class ExposedFilterQueryService : FilterQueryService {
override suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<FilterQueryModel> {
return Filters
.rightJoin(FilterKeywords)
.selectAll()
.where { Filters.userId eq userId }
.toFilterQueryModel()
}
override suspend fun findByUserId(userId: Long): List<FilterQueryModel> {
return Filters
.rightJoin(FilterKeywords)
.selectAll()
.where { Filters.userId eq userId }
.toFilterQueryModel()
}
override suspend fun findByUserIdAndId(userId: Long, id: Long): FilterQueryModel? {
return Filters
.leftJoin(FilterKeywords)
.selectAll()
.where { Filters.userId eq userId and (Filters.id eq id) }
.toFilterQueryModel()
.firstOrNull()
}
override suspend fun findByUserIdAndKeywordId(userId: Long, keywordId: Long): FilterQueryModel? {
return Filters
.leftJoin(FilterKeywords)
.selectAll()
.where { Filters.userId eq userId and (FilterKeywords.id eq keywordId) }
.toFilterQueryModel()
.firstOrNull()
}
private fun Query.toFilterQueryModel(): List<FilterQueryModel> {
return this
.groupBy { it[Filters.id] }
.map { it.value }
.map {
FilterQueryModel.of(
it.first().toFilter(),
it.map { resultRow -> resultRow.toFilterKeyword() }
)
}
}
}

View File

@ -0,0 +1,27 @@
package dev.usbharu.hideout.core.query.model
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword
data class FilterQueryModel(
val id: Long,
val userId: Long,
val name: String,
val context: List<FilterType>,
val filterAction: FilterAction,
val keywords: List<FilterKeyword>
) {
companion object {
@Suppress("FunctionMinLength")
fun of(filter: Filter, keywords: List<FilterKeyword>): FilterQueryModel = FilterQueryModel(
id = filter.id,
userId = filter.userId,
name = filter.name,
context = filter.context,
filterAction = filter.filterAction,
keywords = keywords
)
}
}

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.core.query.model
import dev.usbharu.hideout.core.domain.model.filter.FilterType
interface FilterQueryService {
suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<FilterQueryModel>
suspend fun findByUserId(userId: Long): List<FilterQueryModel>
suspend fun findByUserIdAndId(userId: Long, id: Long): FilterQueryModel?
suspend fun findByUserIdAndKeywordId(userId: Long, keywordId: Long): FilterQueryModel?
}

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
data class FilterKeyword(
val keyword: String,
val mode: FilterMode
)

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.query.model.FilterQueryModel
data class FilterResult(
val filter: FilterQueryModel,
val keyword: String,
)

View File

@ -0,0 +1,14 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.query.model.FilterQueryModel
interface MuteProcessService {
suspend fun processMute(post: Post, context: List<FilterType>, filters: List<FilterQueryModel>): FilterResult?
suspend fun processMutes(
posts: List<Post>,
context: List<FilterType>,
filters: List<FilterQueryModel>
): Map<Post, FilterResult>
}

View File

@ -0,0 +1,123 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterMode.*
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class MuteProcessServiceImpl : MuteProcessService {
override suspend fun processMute(
post: Post,
context: List<FilterType>,
filters: List<FilterQueryModel>
): FilterResult? {
val preprocess = preprocess(context, filters)
return processMute(post, preprocess)
}
private suspend fun processMute(
post: Post,
preprocess: List<PreProcessedFilter>
): FilterResult? {
logger.trace("process mute post: {}", post)
if (post.overview != null) {
val processMute = processMute(post.overview, preprocess)
if (processMute != null) {
return processMute
}
}
val processMute = processMute(post.text, preprocess)
if (processMute != null) {
return processMute
}
return null
}
override suspend fun processMutes(
posts: List<Post>,
context: List<FilterType>,
filters: List<FilterQueryModel>
): Map<Post, FilterResult> {
val preprocess = preprocess(context, filters)
return posts.mapNotNull { it to (processMute(it, preprocess) ?: return@mapNotNull null) }.toMap()
}
private suspend fun processMute(string: String, filters: List<PreProcessedFilter>): FilterResult? {
for (filter in filters) {
val matchEntire = filter.regex.find(string)
if (matchEntire != null) {
return FilterResult(filter.filter, matchEntire.value)
}
}
return null
}
private fun preprocess(context: List<FilterType>, filters: List<FilterQueryModel>): List<PreProcessedFilter> {
val filterQueryModelList = filters
.filter { it.context.any(context::contains) }
.map {
PreProcessedFilter(
it,
precompileRegex(it)
)
}
return filterQueryModelList
}
private fun precompileRegex(filter: FilterQueryModel): Regex {
logger.trace("precompile regex. filter: {}", filter)
val regexList = mutableListOf<Regex>()
val noneRegexStrings = mutableListOf<String>()
val wholeRegexStrings = mutableListOf<String>()
for (keyword in filter.keywords) {
when (keyword.mode) {
WHOLE_WORD -> wholeRegexStrings.add(keyword.keyword)
REGEX -> regexList.add(Regex(keyword.keyword))
NONE -> noneRegexStrings.add(keyword.keyword)
}
}
val noneRegex = noneRegexStrings.joinToString("|", "(", ")")
val wholeRegex = wholeRegexStrings.joinToString("|", "\\b(", ")\\b")
val regex = if (noneRegexStrings.isNotEmpty() && wholeRegexStrings.isNotEmpty()) {
Regex("$noneRegex|$wholeRegex")
} else if (noneRegexStrings.isNotEmpty()) {
noneRegex.toRegex()
} else if (wholeRegexStrings.isNotEmpty()) {
wholeRegex.toRegex()
} else {
null
}
if (regex != null) {
regexList.add(regex)
}
val pattern = regexList.joinToString(")|(", "(", ")")
logger.trace("precompiled regex {}", pattern)
return Regex(pattern)
}
data class PreProcessedFilter(val filter: FilterQueryModel, val regex: Regex)
companion object {
private val logger = LoggerFactory.getLogger(MuteProcessServiceImpl::class.java)
}
}

View File

@ -0,0 +1,17 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.query.model.FilterQueryModel
interface MuteService {
suspend fun createFilter(
title: String,
context: List<FilterType>,
action: FilterAction,
keywords: List<FilterKeyword>,
loginUser: Long
): FilterQueryModel
suspend fun getFilters(userId: Long, types: List<FilterType> = emptyList()): List<FilterQueryModel>
}

View File

@ -0,0 +1,50 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterRepository
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeywordRepository
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import dev.usbharu.hideout.core.query.model.FilterQueryService
import org.springframework.stereotype.Service
@Service
class MuteServiceImpl(
private val filterRepository: FilterRepository,
private val filterKeywordRepository: FilterKeywordRepository,
private val filterQueryService: FilterQueryService
) : MuteService {
override suspend fun createFilter(
title: String,
context: List<FilterType>,
action: FilterAction,
keywords: List<FilterKeyword>,
loginUser: Long
): FilterQueryModel {
val filter = Filter(
filterRepository.generateId(),
loginUser,
title,
context,
action
)
val filterKeywordList = keywords.map {
dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword(
filterKeywordRepository.generateId(),
filter.id,
it.keyword,
it.mode
)
}
val savedFilter = filterRepository.save(filter)
filterKeywordRepository.saveAll(filterKeywordList)
return FilterQueryModel.of(savedFilter, filterKeywordList)
}
override suspend fun getFilters(userId: Long, types: List<FilterType>): List<FilterQueryModel> =
filterQueryService.findByUserIdAndType(userId, types)
}

View File

@ -40,11 +40,12 @@ class JsonOrFormModelMethodProcessor(
return try {
modelAttributeMethodProcessor.resolveArgument(parameter, mavContainer, webRequest, binderFactory)
} catch (ignore: Exception) {
} catch (exception: Exception) {
try {
requestResponseBodyMethodProcessor.resolveArgument(parameter, mavContainer, webRequest, binderFactory)
} catch (e: Exception) {
logger.warn("Failed to bind request", e)
logger.warn("Failed to bind request (1)", exception)
logger.warn("Failed to bind request (2)", e)
}
}
}

View File

@ -0,0 +1,159 @@
package dev.usbharu.hideout.mastodon.interfaces.api.filter
import dev.usbharu.hideout.controller.mastodon.generated.FilterApi
import dev.usbharu.hideout.core.infrastructure.springframework.security.LoginUserContextHolder
import dev.usbharu.hideout.domain.mastodon.model.generated.*
import dev.usbharu.hideout.mastodon.service.filter.MastodonFilterApiService
import kotlinx.coroutines.flow.Flow
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
@Controller
class MastodonFilterApiController(
private val mastodonFilterApiService: MastodonFilterApiService,
private val loginUserContextHolder: LoginUserContextHolder
) : FilterApi {
override suspend fun apiV1FiltersIdDelete(id: String): ResponseEntity<Any> {
mastodonFilterApiService.deleteV1FilterById(loginUserContextHolder.getLoginUserId(), id.toLong())
return ResponseEntity.ok().build()
}
override suspend fun apiV1FiltersIdGet(
id: String
): ResponseEntity<V1Filter> {
return ResponseEntity.ok(
mastodonFilterApiService.getV1FilterById(
loginUserContextHolder.getLoginUserId(),
id.toLong()
)
)
}
override suspend fun apiV1FiltersIdPut(
id: String,
phrase: String?,
context: List<String>?,
irreversible: Boolean?,
wholeWord: Boolean?,
expiresIn: Int?
): ResponseEntity<V1Filter> = super.apiV1FiltersIdPut(id, phrase, context, irreversible, wholeWord, expiresIn)
override suspend fun apiV1FiltersPost(v1FilterPostRequest: V1FilterPostRequest): ResponseEntity<V1Filter> {
return ResponseEntity.ok(
mastodonFilterApiService.createByV1Filter(loginUserContextHolder.getLoginUserId(), v1FilterPostRequest)
)
}
override suspend fun apiV2FiltersFilterIdKeywordsPost(
filterId: String,
filterKeywordsPostRequest: FilterKeywordsPostRequest
): ResponseEntity<FilterKeyword> {
return ResponseEntity.ok(
mastodonFilterApiService.addKeyword(
loginUserContextHolder.getLoginUserId(),
filterId.toLong(),
filterKeywordsPostRequest
)
)
}
override suspend fun apiV2FiltersFilterIdStatusesPost(
filterId: String,
filterStatusRequest: FilterStatusRequest
): ResponseEntity<FilterStatus> {
return ResponseEntity.ok(
mastodonFilterApiService.addFilterStatus(
loginUserContextHolder.getLoginUserId(),
filterId.toLong(),
filterStatusRequest
)
)
}
override fun apiV1FiltersGet(): ResponseEntity<Flow<V1Filter>> =
ResponseEntity.ok(mastodonFilterApiService.v1Filters(loginUserContextHolder.getLoginUserId()))
override fun apiV2FiltersFilterIdKeywordsGet(filterId: String): ResponseEntity<Flow<FilterKeyword>> {
return ResponseEntity.ok(
mastodonFilterApiService.filterKeywords(
loginUserContextHolder.getLoginUserId(),
filterId.toLong()
)
)
}
override fun apiV2FiltersFilterIdStatusesGet(filterId: String): ResponseEntity<Flow<FilterStatus>> {
return ResponseEntity.ok(
mastodonFilterApiService.filterStatuses(
loginUserContextHolder.getLoginUserId(),
filterId.toLong()
)
)
}
override fun apiV2FiltersGet(): ResponseEntity<Flow<Filter>> {
return ResponseEntity.ok(mastodonFilterApiService.filters(loginUserContextHolder.getLoginUserId()))
}
override suspend fun apiV2FiltersIdDelete(id: String): ResponseEntity<Any> {
mastodonFilterApiService.deleteById(loginUserContextHolder.getLoginUserId(), id.toLong())
return ResponseEntity.ok().build()
}
override suspend fun apiV2FiltersIdGet(id: String): ResponseEntity<Filter> =
ResponseEntity.ok(mastodonFilterApiService.getById(loginUserContextHolder.getLoginUserId(), id.toLong()))
override suspend fun apiV2FiltersIdPut(
id: String,
title: String?,
context: List<String>?,
filterAction: String?,
expiresIn: Int?,
keywordsAttributes: List<FilterPubRequestKeyword>?
): ResponseEntity<Filter> =
super.apiV2FiltersIdPut(id, title, context, filterAction, expiresIn, keywordsAttributes)
override suspend fun apiV2FiltersKeywordsIdDelete(id: String): ResponseEntity<Any> {
mastodonFilterApiService.deleteKeyword(loginUserContextHolder.getLoginUserId(), id.toLong())
return ResponseEntity.ok().build()
}
override suspend fun apiV2FiltersKeywordsIdGet(id: String): ResponseEntity<FilterKeyword> {
return ResponseEntity.ok(
mastodonFilterApiService.getKeywordById(
loginUserContextHolder.getLoginUserId(),
id.toLong()
)
)
}
override suspend fun apiV2FiltersKeywordsIdPut(
id: String,
keyword: String?,
wholeWord: Boolean?,
regex: Boolean?
): ResponseEntity<FilterKeyword> = super.apiV2FiltersKeywordsIdPut(id, keyword, wholeWord, regex)
override suspend fun apiV2FiltersPost(filterPostRequest: FilterPostRequest): ResponseEntity<Filter> =
ResponseEntity.ok(
mastodonFilterApiService.createFilter(
loginUserContextHolder.getLoginUserId(),
filterPostRequest
)
)
override suspend fun apiV2FiltersStatusesIdDelete(id: String): ResponseEntity<Any> {
mastodonFilterApiService.deleteFilterStatusById(loginUserContextHolder.getLoginUserId(), id.toLong())
return ResponseEntity.ok().build()
}
override suspend fun apiV2FiltersStatusesIdGet(id: String): ResponseEntity<FilterStatus> {
return ResponseEntity.ok(
mastodonFilterApiService.getFilterStatusById(
loginUserContextHolder.getLoginUserId(),
id.toLong()
)
)
}
}

View File

@ -20,7 +20,7 @@ import kotlin.math.min
@Suppress("TooManyFunctions")
interface AccountApiService {
@Suppress("ongParameterList")
@Suppress("LongParameterList")
suspend fun accountsStatuses(
userid: Long,
onlyMedia: Boolean,

View File

@ -0,0 +1,285 @@
package dev.usbharu.hideout.mastodon.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction.hide
import dev.usbharu.hideout.core.domain.model.filter.FilterAction.warn
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
import dev.usbharu.hideout.core.domain.model.filter.FilterRepository
import dev.usbharu.hideout.core.domain.model.filter.FilterType.*
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeywordRepository
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import dev.usbharu.hideout.core.query.model.FilterQueryService
import dev.usbharu.hideout.core.service.filter.MuteService
import dev.usbharu.hideout.domain.mastodon.model.generated.*
import dev.usbharu.hideout.domain.mastodon.model.generated.FilterPostRequest.FilterAction
import dev.usbharu.hideout.domain.mastodon.model.generated.V1Filter.Context
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import org.springframework.stereotype.Service
@Suppress("TooManyFunctions")
interface MastodonFilterApiService {
fun v1Filters(userId: Long): Flow<V1Filter>
suspend fun deleteV1FilterById(userId: Long, id: Long)
suspend fun getV1FilterById(userId: Long, id: Long): V1Filter?
suspend fun createByV1Filter(userId: Long, v1FilterRequest: V1FilterPostRequest): V1Filter
fun filterKeywords(userId: Long, filterId: Long): Flow<FilterKeyword>
suspend fun addKeyword(userId: Long, filterId: Long, keyword: FilterKeywordsPostRequest): FilterKeyword
fun filterStatuses(userId: Long, filterId: Long): Flow<FilterStatus>
suspend fun addFilterStatus(userId: Long, filterId: Long, filterStatusRequest: FilterStatusRequest): FilterStatus
fun filters(userId: Long): Flow<Filter>
suspend fun deleteById(userId: Long, filterId: Long)
suspend fun getById(userId: Long, filterId: Long): Filter?
suspend fun deleteKeyword(userId: Long, keywordId: Long)
suspend fun getKeywordById(userId: Long, keywordId: Long): FilterKeyword?
suspend fun createFilter(userId: Long, filterPostRequest: FilterPostRequest): Filter
suspend fun deleteFilterStatusById(userId: Long, filterPostsId: Long)
suspend fun getFilterStatusById(userId: Long, filterPostsId: Long): FilterStatus?
}
@Service
class MastodonFilterApiServiceImpl(
private val muteService: MuteService,
private val filterQueryService: FilterQueryService,
private val filterRepository: FilterRepository,
private val filterKeywordRepository: FilterKeywordRepository
) : MastodonFilterApiService {
override fun v1Filters(userId: Long): Flow<V1Filter> {
return runBlocking { filterQueryService.findByUserId(userId) }.flatMap { filterQueryModel ->
filterQueryModel.keywords.map {
V1Filter(
id = it.id.toString(),
phrase = it.keyword,
context = filterQueryModel.context.map { filterType ->
when (filterType) {
home -> Context.home
notifications -> Context.notifications
public -> Context.public
thread -> Context.thread
account -> Context.account
}
},
expiresAt = null,
irreversible = false,
wholeWord = (it.mode != FilterMode.WHOLE_WORD).not()
)
}
}.asFlow()
}
override suspend fun deleteV1FilterById(userId: Long, id: Long) {
val keywordId = filterQueryService.findByUserIdAndKeywordId(userId, id)?.keywords?.singleOrNull()?.id ?: return
filterKeywordRepository.deleteById(keywordId)
}
override suspend fun getV1FilterById(userId: Long, id: Long): V1Filter? {
val filterQueryModel = filterQueryService.findByUserIdAndKeywordId(userId, id) ?: return null
val filterKeyword = filterQueryModel.keywords.firstOrNull() ?: return null
return v1Filter(filterQueryModel, filterKeyword)
}
private fun v1Filter(
filterQueryModel: FilterQueryModel,
filterKeyword: dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword
) = V1Filter(
id = filterQueryModel.id.toString(),
phrase = filterKeyword.keyword,
context = filterQueryModel.context.map {
when (it) {
home -> Context.home
notifications -> Context.notifications
public -> Context.public
thread -> Context.thread
account -> Context.account
}
},
expiresAt = null,
irreversible = false,
wholeWord = filterKeyword.mode == FilterMode.WHOLE_WORD
)
override suspend fun createByV1Filter(userId: Long, v1FilterRequest: V1FilterPostRequest): V1Filter {
val createFilter = muteService.createFilter(
title = v1FilterRequest.phrase,
context = v1FilterRequest.context.map {
when (it) {
V1FilterPostRequest.Context.home -> home
V1FilterPostRequest.Context.notifications -> notifications
V1FilterPostRequest.Context.public -> public
V1FilterPostRequest.Context.thread -> thread
V1FilterPostRequest.Context.account -> account
}
},
action = warn,
keywords = listOf(
dev.usbharu.hideout.core.service.filter.FilterKeyword(
v1FilterRequest.phrase,
if (v1FilterRequest.wholeWord == true) {
FilterMode.WHOLE_WORD
} else {
FilterMode.NONE
}
)
),
loginUser = userId
)
return v1Filter(createFilter, createFilter.keywords.first())
}
override fun filterKeywords(userId: Long, filterId: Long): Flow<FilterKeyword> =
runBlocking { filterQueryService.findByUserIdAndId(userId, filterId) }
?.keywords
?.map {
toFilterKeyword(
it
)
}
.orEmpty()
.asFlow()
override suspend fun addKeyword(userId: Long, filterId: Long, keyword: FilterKeywordsPostRequest): FilterKeyword {
val id = filterQueryService.findByUserIdAndId(userId, filterId)?.id
?: throw IllegalArgumentException("filter not found.")
val filterKeyword = filterKeywordRepository.save(
dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword(
id = filterKeywordRepository.generateId(),
filterId = id,
keyword = keyword.keyword,
mode = if (keyword.regex == true) {
FilterMode.REGEX
} else if (keyword.wholeWord == true) {
FilterMode.WHOLE_WORD
} else {
FilterMode.NONE
}
)
)
return toFilterKeyword(filterKeyword)
}
override fun filterStatuses(userId: Long, filterId: Long): Flow<FilterStatus> = emptyFlow()
override suspend fun addFilterStatus(
userId: Long,
filterId: Long,
filterStatusRequest: FilterStatusRequest
): FilterStatus {
TODO()
}
override fun filters(userId: Long): Flow<Filter> =
runBlocking { filterQueryService.findByUserId(userId) }.map { filterQueryModel ->
toFilter(filterQueryModel)
}.asFlow()
private fun toFilter(filterQueryModel: FilterQueryModel) = Filter(
id = filterQueryModel.id.toString(),
title = filterQueryModel.name,
context = filterQueryModel.context.map {
when (it) {
home -> Filter.Context.home
notifications -> Filter.Context.notifications
public -> Filter.Context.public
thread -> Filter.Context.thread
account -> Filter.Context.account
}
},
expiresAt = null,
filterAction = when (filterQueryModel.filterAction) {
warn -> Filter.FilterAction.warn
hide -> Filter.FilterAction.hide
},
keywords = filterQueryModel.keywords.map {
toFilterKeyword(it)
},
statuses = null
)
private fun toFilterKeyword(it: dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword) = FilterKeyword(
it.id.toString(),
it.keyword,
it.mode == FilterMode.WHOLE_WORD
)
override suspend fun deleteById(userId: Long, filterId: Long) =
filterRepository.deleteByUserIdAndId(userId, filterId)
override suspend fun getById(userId: Long, filterId: Long): Filter? =
filterQueryService.findByUserIdAndId(userId, filterId)?.let { toFilter(it) }
override suspend fun deleteKeyword(userId: Long, keywordId: Long) {
val id = filterQueryService.findByUserIdAndKeywordId(userId, keywordId)?.keywords?.singleOrNull()?.id ?: return
filterKeywordRepository.deleteById(id)
}
override suspend fun getKeywordById(userId: Long, keywordId: Long): FilterKeyword? {
return filterQueryService
.findByUserIdAndKeywordId(userId, keywordId)
?.keywords
?.firstOrNull()
?.let { toFilterKeyword(it) }
}
override suspend fun createFilter(userId: Long, filterPostRequest: FilterPostRequest): Filter {
val keywords = filterPostRequest.keywordsAttributes.orEmpty().map {
dev.usbharu.hideout.core.service.filter.FilterKeyword(
it.keyword,
if (it.regex == true) {
FilterMode.REGEX
} else if (it.wholeWord == true) {
FilterMode.WHOLE_WORD
} else {
FilterMode.NONE
}
)
}
return toFilter(
muteService.createFilter(
title = filterPostRequest.title,
context = filterPostRequest.context.map {
when (it) {
FilterPostRequest.Context.home -> home
FilterPostRequest.Context.notifications -> notifications
FilterPostRequest.Context.public -> public
FilterPostRequest.Context.thread -> thread
FilterPostRequest.Context.account -> account
}
},
action = when (filterPostRequest.filterAction) {
FilterAction.warn -> warn
FilterAction.hide -> warn
null -> warn
},
keywords = keywords,
loginUser = userId
)
)
}
override suspend fun deleteFilterStatusById(userId: Long, filterPostsId: Long) = Unit
override suspend fun getFilterStatusById(userId: Long, filterPostsId: Long): FilterStatus? = null
}

View File

@ -0,0 +1,18 @@
create table if not exists filters
(
id bigint primary key not null,
user_id bigint not null,
name varchar(255) not null,
context varchar(500) not null,
action varchar(255) not null,
constraint fk_filters_user_id__id foreign key (user_id) references actors (id) on delete cascade on update cascade
);
create table if not exists filter_keywords
(
id bigint primary key not null,
filter_id bigint not null,
keyword varchar(1000) not null,
mode varchar(100) not null,
constraint fk_filter_keywords_filter_id__id foreign key (filter_id) references filters (id) on delete cascade on update cascade
);

View File

@ -21,6 +21,8 @@ tags:
description: media
- name: notification
description: notification
- name: filter
description: filter
paths:
/api/v2/instance:
@ -922,6 +924,424 @@ paths:
schema:
type: object
/api/v2/filters:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Filter"
post:
tags:
- filter
security:
- OAuth2:
- "write:filters"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/FilterPostRequest"
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/FilterPostRequest"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Filter"
/api/v2/filters/{id}:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Filter"
put:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/FilterPutRequest"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Filter"
delete:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: object
/api/v2/filters/{filter_id}/keywords:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
parameters:
- in: path
name: filter_id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/FilterKeyword"
post:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: filter_id
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/FilterKeywordsPostRequest"
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/FilterKeywordsPostRequest"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/FilterKeyword"
/api/v2/filters/keywords/{id}:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/FilterKeyword"
put:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/FilterKeywordsPutRequest"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/FilterKeyword"
delete:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: object
/api/v2/filters/{filter_id}/statuses:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
parameters:
- in: path
name: filter_id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/FilterStatus"
post:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: filter_id
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/FilterStatusRequest"
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/FilterStatusRequest"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/FilterStatus"
/api/v2/filters/statuses/{id}:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/FilterStatus"
delete:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: object
/api/v1/filters:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/V1Filter"
post:
tags:
- filter
security:
- OAuth2:
- "write:filters"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/V1FilterPostRequest"
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/V1FilterPostRequest"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/V1Filter"
/api/v1/filters/{id}:
get:
tags:
- filter
security:
- OAuth2:
- "read:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/V1Filter"
put:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/V1FilterPutRequest"
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/V1Filter"
delete:
tags:
- filter
security:
- OAuth2:
- "write:filters"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: object
components:
schemas:
V1MediaRequest:
@ -1476,13 +1896,15 @@ components:
title:
type: string
context:
type: string
enum:
- home
- notifications
- public
- thread
- account
type: array
items:
type: string
enum:
- home
- notifications
- public
- thread
- account
expires_at:
type: string
nullable: true
@ -1518,6 +1940,191 @@ components:
status_id:
type: string
V1Filter:
type: object
properties:
id:
type: string
phrase:
type: string
context:
type: array
items:
enum:
- home
- notifications
- thread
- public
- account
expires_at:
type: string
irreversible:
type: boolean
whole_word:
type: boolean
V1FilterPostRequest:
type: object
properties:
phrase:
type: string
context:
type: array
items:
type: string
enum:
- home
- notifications
- public
- thread
- account
irreversible:
type: boolean
whole_word:
type: boolean
expires_in:
type: integer
required:
- phrase
- context
V1FilterPutRequest:
type: object
properties:
phrase:
type: string
context:
type: array
items:
type: string
enum:
- home
- notifications
- public
- thread
- account
irreversible:
type: boolean
whole_word:
type: boolean
expires_in:
type: integer
FilterPostRequest:
type: object
properties:
title:
type: string
context:
type: array
items:
type: string
enum:
- home
- notifications
- public
- thread
- account
filter_action:
type: string
enum:
- warn
- hide
expires_in:
type: integer
keywords_attributes:
type: array
items:
$ref: "#/components/schemas/FilterPostRequestKeyword"
required:
- title
- context
FilterPostRequestKeyword:
type: object
properties:
keyword:
type: string
whole_word:
type: boolean
default: false
regex:
type: boolean
default: false
required:
- keyword
FilterKeywordsPostRequest:
type: object
properties:
keyword:
type: string
whole_word:
type: boolean
default: false
regex:
type: boolean
default: false
required:
- keyword
FilterKeywordsPutRequest:
type: object
properties:
keyword:
type: string
whole_word:
type: boolean
regex:
type: boolean
FilterPutRequest:
type: object
properties:
title:
type: string
context:
type: array
items:
type: string
enum:
- homa
- notifications
- public
- thread
- account
filter_action:
type: string
enum:
- warn
- hide
expires_in:
type: integer
keywords_attributes:
type: array
items:
$ref: "#/components/schemas/FilterPubRequestKeyword"
FilterPubRequestKeyword:
type: object
properties:
keyword:
type: string
whole_word:
type: boolean
regex:
type: boolean
id:
type: string
_destroy:
type: boolean
FilterStatusRequest:
type: object
properties:
status_id:
type: string
Instance:
type: object
properties:

View File

@ -0,0 +1,388 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import utils.PostBuilder
class MuteProcessServiceImplTest {
@Test
fun 単純な文字列にマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mute",
FilterMode.NONE
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 複数の文字列でマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mate",
FilterMode.NONE
),
FilterKeyword(
1,
1,
"mata",
FilterMode.NONE
),
FilterKeyword(
1,
1,
"mute",
FilterMode.NONE
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 単語にマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mute",
FilterMode.WHOLE_WORD
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 単語以外にはマッチしない() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mutetest")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mute",
FilterMode.WHOLE_WORD
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isNull()
}
@Test
fun 複数の単語にマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mate",
FilterMode.WHOLE_WORD
),
FilterKeyword(
1,
1,
"mata",
FilterMode.WHOLE_WORD
),
FilterKeyword(
1,
1,
"mute",
FilterMode.WHOLE_WORD
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 正規表現も使える() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"e\\st",
FilterMode.REGEX
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "e t"))
}
@Test
fun cw文字にマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(overview = "mute test", text = "hello")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"e\\st",
FilterMode.REGEX
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "e t"))
}
@Test
fun 文字列と単語と正規表現を同時に使える() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"e\\st",
FilterMode.REGEX
),
FilterKeyword(
2,
1,
"mute",
FilterMode.NONE
),
FilterKeyword(
3,
1,
"test",
FilterMode.WHOLE_WORD
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 複数の投稿を処理できる() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mute",
FilterMode.NONE
)
)
)
val posts = listOf(
PostBuilder.of(text = "mute"), PostBuilder.of(text = "mutes"), PostBuilder.of(text = "hoge")
)
val actual = muteProcessServiceImpl.processMutes(
posts,
FilterType.entries.toList(),
listOf(
filterQueryModel
)
)
assertThat(actual)
.hasSize(2)
.containsEntry(posts[0], FilterResult(filterQueryModel, "mute"))
.containsEntry(posts[1], FilterResult(filterQueryModel, "mute"))
}
@Test
fun 何もマッチしないとnullが返ってくる() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"fuga",
FilterMode.NONE
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isNull()
}
@Test
fun Cwで何もマッチしないと本文を確認する() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(overview = "hage", text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"fuga",
FilterMode.NONE
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isNull()
}
}

View File

@ -0,0 +1,96 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.*
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeywordRepository
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import dev.usbharu.hideout.core.query.model.FilterQueryService
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.junit.jupiter.MockitoExtension
import org.mockito.kotlin.*
@ExtendWith(MockitoExtension::class)
class MuteServiceImplTest {
@Mock
private lateinit var filterRepository: FilterRepository
@Mock
private lateinit var filterKeywordRepository: FilterKeywordRepository
@Mock
private lateinit var filterQueryService: FilterQueryService
@InjectMocks
private lateinit var muteServiceImpl: MuteServiceImpl
@Test
fun createFilter() = runTest {
whenever(filterRepository.generateId()).doReturn(1)
whenever(filterKeywordRepository.generateId()).doReturn(1)
whenever(filterRepository.save(any())).doAnswer { it.arguments[0]!! as Filter }
val createFilter = muteServiceImpl.createFilter(
title = "hoge",
context = listOf(FilterType.home, FilterType.public),
action = FilterAction.warn,
keywords = listOf(
FilterKeyword(
"fuga",
FilterMode.NONE
)
),
loginUser = 1
)
assertThat(createFilter).isEqualTo(
FilterQueryModel(
1,
1,
"hoge",
listOf(FilterType.home, FilterType.public),
FilterAction.warn,
keywords = listOf(
dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword(1, 1, "fuga", FilterMode.NONE)
)
)
)
}
@Test
fun getFilters() = runTest {
whenever(filterQueryService.findByUserIdAndType(any(), any())).doReturn(
listOf(
FilterQueryModel(
1,
1,
"hoge",
listOf(FilterType.home),
FilterAction.warn,
listOf(
dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword(
1,
1,
"fuga",
FilterMode.NONE
)
)
)
)
)
muteServiceImpl.getFilters(1, listOf(FilterType.home))
}
@Test
fun `getFilters 何も指定しない`() = runTest {
whenever(filterQueryService.findByUserIdAndType(any(), eq(emptyList()))).doReturn(emptyList())
muteServiceImpl.getFilters(1)
}
}