Ignore unknown keys when deserializing JSON

This commit is contained in:
Erik C. Thauvin 2025-05-30 09:49:50 -07:00
parent a39948e174
commit 722dd9a888
Signed by: erik
GPG key ID: 776702A6A2DA330E
9 changed files with 635 additions and 406 deletions

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Erik's Code Style" />
</state>
</component>

View file

@ -12,6 +12,7 @@
<ID>ReturnCount:Akismet.kt$Akismet$@JvmOverloads fun executeMethod(apiUrl: HttpUrl, formBody: FormBody, trueOnError: Boolean = false): Boolean</ID>
<ID>ReturnCount:AkismetTest.kt$fun getKey(key: String): String</ID>
<ID>TooManyFunctions:CommentConfig.kt$CommentConfig$Builder</ID>
<ID>WildcardImport:AkismetCommentTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:AkismetTests.kt$import assertk.assertions.*</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -32,8 +32,10 @@
package net.thauvin.erik.akismet
import jakarta.servlet.http.HttpServletRequest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
private fun String?.ifNull() = this ?: ""
@ -54,6 +56,8 @@ private fun String?.ifNull() = this ?: ""
* @param userAgent User agent string of the web browser submitting the comment
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
open class AkismetComment(val userIp: String, val userAgent: String) {
companion object {
/**

View file

@ -0,0 +1,267 @@
/*
* AkismetCommentTest.kt
*
* Copyright 2019-2025 Erik C. Thauvin (erik@thauvin.net)
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* Neither the name of this project nor the names of its contributors may be
* used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.thauvin.erik.akismet
import assertk.all
import assertk.assertThat
import assertk.assertions.*
import jakarta.servlet.http.HttpServletRequest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertThrows
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito.mock
import org.mockito.kotlin.whenever
import java.time.LocalDateTime
import java.util.*
import kotlin.test.assertTrue
@ExtendWith(BeforeAllTests::class)
class AkismetCommentTest {
private val apiKey = TestUtils.getKey("AKISMET_API_KEY")
private val blog = TestUtils.getKey("AKISMET_BLOG")
private val config = CommentConfig.Builder(
userIp = "127.0.0.1",
userAgent = "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.2) Gecko/20100115 Firefox/3.6"
)
.referrer("https://www.google.com")
.permalink("https://yourblogdomainname.com/blog/post=1")
.type(CommentType.COMMENT)
.author("admin")
.authorEmail("test@test.com")
.authorUrl("https://www.CheckOutMyCoolSite.com")
.content("It means a lot that you would take the time to review our software. Thanks again.")
.dateGmt(Akismet.dateToGmt(LocalDateTime.of(2025, 5, 29, 0, 0, 0)))
.postModifiedGmt(Akismet.dateToGmt(LocalDateTime.of(2025, 5, 29, 1, 0, 0)))
.blogLang("en")
.blogCharset("UTF-8")
.userRole(AkismetComment.ADMIN_ROLE)
.recheckReason("edit")
.isTest(true)
.build()
@Test
fun `toJson returns correct JSON representation`() {
val comment = AkismetComment("127.0.0.1", "TestAgent")
comment.referrer = "https://example.com"
comment.permalink = "https://permalink.com"
comment.type = CommentType.COMMENT
comment.author = "Author"
comment.content = "Sample comment"
val json = comment.toJson()
assertEquals(Json.encodeToString(comment), json)
assertThat(comment).prop(AkismetComment::toString).isEqualTo(json)
}
@Test
fun `Equals and hashCode methods work as expected`() {
val comment1 = AkismetComment("127.0.0.1", "TestAgent")
val comment2 = AkismetComment("127.0.0.1", "TestAgent")
val comment3 = AkismetComment("192.168.0.1", "OtherAgent")
assertEquals(comment1, comment2)
assertNotEquals(comment1, comment3)
assertEquals(comment1.hashCode(), comment2.hashCode())
assertNotEquals(comment1.hashCode(), comment3.hashCode())
}
@Test
fun `Property setters handle null values correctly`() {
val comment = AkismetComment("127.0.0.1", "TestAgent")
comment.referrer = null
comment.permalink = null
comment.author = null
comment.authorEmail = null
comment.authorUrl = null
comment.content = null
comment.dateGmt = null
comment.postModifiedGmt = null
comment.blogLang = null
comment.blogCharset = null
comment.userRole = null
comment.recheckReason = null
assertThat(comment).all {
prop(AkismetComment::referrer).isEqualTo("")
prop(AkismetComment::permalink).isEqualTo("")
prop(AkismetComment::author).isEqualTo("")
prop(AkismetComment::authorEmail).isEqualTo("")
prop(AkismetComment::authorUrl).isEqualTo("")
prop(AkismetComment::content).isEqualTo("")
prop(AkismetComment::dateGmt).isEqualTo("")
prop(AkismetComment::postModifiedGmt).isEqualTo("")
prop(AkismetComment::blogLang).isEqualTo("")
prop(AkismetComment::blogCharset).isEqualTo("")
prop(AkismetComment::userRole).isEqualTo("")
prop(AkismetComment::recheckReason).isEqualTo("")
prop(AkismetComment::isTest).isFalse()
prop(AkismetComment::serverEnv).isEmpty()
}
}
@Nested
@DisplayName("Check Comment Tests")
inner class CheckCommentTests {
@Test
fun `Check comment with admin role`() {
val akismet = Akismet(apiKey, blog)
val comment = AkismetComment(config).apply {
userRole = AkismetComment.ADMIN_ROLE
isTest = true
}
assertThat(akismet.checkComment(comment), "checkComment()").isFalse()
assertThat(akismet).prop(Akismet::response).isEqualTo("false")
}
@Test
fun `Check comment with no user role`() {
val akismet = Akismet(apiKey, blog)
val comment = AkismetComment(config).apply {
userRole = ""
isTest = true
}
assertTrue(akismet.checkComment(comment), "checkComment()")
assertThat(akismet).prop(Akismet::response).isEqualTo("true")
}
@Test
fun `Check comment with no user IP or user agent`() {
val akismet = Akismet(apiKey, blog)
assertThrows(
java.lang.IllegalArgumentException::class.java
) { akismet.checkComment(AkismetComment("", "")) }
}
}
@Nested
@DisplayName("Constructor Tests")
inner class ConstructorTests {
@Test
fun `Constructor with userIp and userAgent initializes fields correctly`() {
val comment = AkismetComment("127.0.0.1", "TestAgent")
assertThat(comment).all {
prop(AkismetComment::userIp).isEqualTo("127.0.0.1")
prop(AkismetComment::userAgent).isEqualTo("TestAgent")
prop(AkismetComment::referrer).isEqualTo("")
prop(AkismetComment::permalink).isEqualTo("")
prop(AkismetComment::type).isEqualTo(CommentType.NONE)
}
}
@Test
fun `Constructor with HttpServletRequest initializes fields correctly`() {
val request = mock(HttpServletRequest::class.java)
whenever(request.remoteAddr).thenReturn("192.168.0.1")
whenever(request.getHeader("User-Agent")).thenReturn("MockAgent")
whenever(request.getHeader("referer")).thenReturn("https://example.com")
whenever(request.requestURI).thenReturn("/test-uri")
whenever(request.headerNames)
.thenReturn(Collections.enumeration(listOf("header1", "header2", "cookie")))
whenever(request.getHeader("header1")).thenReturn("value1")
whenever(request.getHeader("header2")).thenReturn("value2")
whenever(request.getHeader("cookie")).thenReturn("foo")
val comment = AkismetComment(request)
assertThat(comment).all {
prop(AkismetComment::userIp).isEqualTo("192.168.0.1")
prop(AkismetComment::userAgent).startsWith("MockAgent")
prop(AkismetComment::referrer).isEqualTo("https://example.com")
prop(AkismetComment::serverEnv).isEqualTo(
mapOf(
"REMOTE_ADDR" to "192.168.0.1",
"REQUEST_URI" to "/test-uri",
"HTTP_HEADER1" to "value1",
"HTTP_HEADER2" to "value2"
)
)
}
}
@Test
fun `Constructor with CommentConfig initializes fields correctly`() {
val comment = AkismetComment(config).apply {
serverEnv = mapOf("key" to "value")
}
assertThat(comment).all {
prop(AkismetComment::userIp).isEqualTo("127.0.0.1")
prop(AkismetComment::userAgent).startsWith("Mozilla/5.0")
prop(AkismetComment::referrer).isEqualTo("https://www.google.com")
prop(AkismetComment::permalink).isEqualTo("https://yourblogdomainname.com/blog/post=1")
prop(AkismetComment::type).isEqualTo(CommentType.COMMENT)
prop(AkismetComment::author).isEqualTo("admin")
prop(AkismetComment::authorEmail).isEqualTo("test@test.com")
prop(AkismetComment::authorUrl).isEqualTo("https://www.CheckOutMyCoolSite.com")
prop(AkismetComment::content)
.isEqualTo("It means a lot that you would take the time to review our software. Thanks again.")
prop(AkismetComment::dateGmt).isEqualTo("2025-05-29T00:00:00-07:00")
prop(AkismetComment::postModifiedGmt).isEqualTo("2025-05-29T01:00:00-07:00")
prop(AkismetComment::blogLang).isEqualTo("en")
prop(AkismetComment::blogCharset).isEqualTo("UTF-8")
prop(AkismetComment::userRole).isEqualTo(AkismetComment.ADMIN_ROLE)
prop(AkismetComment::recheckReason).isEqualTo("edit")
prop(AkismetComment::isTest).isEqualTo(true)
prop(AkismetComment::serverEnv).isEqualTo(mapOf("key" to "value"))
}
}
}
@Nested
@DisplayName("Submit Test")
inner class SubmitTests {
val akismet = Akismet(apiKey, blog)
val comment = AkismetComment(config).apply {
isTest = true
userRole = AkismetComment.ADMIN_ROLE
}
@Test
fun `Submit ham`() {
assertTrue(akismet.submitHam(comment), "submitHam")
}
@Test
fun `Submit spam`() {
assertTrue(akismet.submitSpam(comment), "submitSpam")
}
}
}

View file

@ -34,285 +34,46 @@ package net.thauvin.erik.akismet
import assertk.all
import assertk.assertThat
import assertk.assertions.*
import jakarta.servlet.http.HttpServletRequest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.MissingFieldException
import kotlinx.serialization.SerializationException
import net.thauvin.erik.akismet.Akismet.Companion.jsonComment
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertThrows
import org.junit.BeforeClass
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.mockito.kotlin.whenever
import java.io.File
import java.io.FileInputStream
import org.junit.jupiter.api.extension.ExtendWith
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.ConsoleHandler
import java.util.logging.Level
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import kotlin.text.contains
/**
* [Akismet] Tests
*
* `AKISMET_API_KEY` and `AKISMET_BLOG` should be set in env vars or `local.properties`
*/
@ExtendWith(BeforeAllTests::class)
class AkismetTests {
private val emptyFormBody = FormBody.Builder().build()
companion object {
private const val REFERER = "https://www.google.com"
private val apiKey = getKey("AKISMET_API_KEY")
private val blog = getKey("AKISMET_BLOG")
private val akismet = Akismet(apiKey, blog)
private val comment = AkismetComment(
userIp = "127.0.0.1",
userAgent = "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.2) Gecko/20100115 Firefox/3.6"
)
private val date = Date()
private val config = CommentConfig.Builder(comment.userIp, comment.userAgent)
.referrer(REFERER)
.permalink("https://yourblogdomainname.com/blog/post=1")
.type(CommentType.COMMENT)
.author("admin")
.authorEmail("test@test.com")
.authorUrl("https://www.CheckOutMyCoolSite.com")
.content("It means a lot that you would take the time to review our software. Thanks again.")
.dateGmt(Akismet.dateToGmt(date))
.postModifiedGmt(Akismet.dateToGmt(date))
.blogLang("en")
.blogCharset("UTF-8")
.userRole(AkismetComment.ADMIN_ROLE)
.recheckReason("edit")
.isTest(true)
.build()
private val mockComment: AkismetComment = AkismetComment(request = getMockRequest())
private val isFirstRun = AtomicBoolean(true)
init {
with(comment) {
referrer = config.referrer
permalink = config.permalink
type = CommentType("comment")
author = config.author
authorEmail = config.authorEmail
authorUrl = config.authorUrl
content = config.content
dateGmt = config.dateGmt
postModifiedGmt = config.postModifiedGmt
blogLang = config.blogLang
blogCharset = config.blogCharset
userRole = config.userRole
recheckReason = config.recheckReason
isTest = config.isTest
}
with(mockComment) {
permalink = comment.permalink
type = comment.type
authorEmail = comment.authorEmail
author = comment.author
authorUrl = comment.authorUrl
content = comment.content
dateGmt = comment.dateGmt
postModifiedGmt = comment.dateGmt
blogLang = comment.blogLang
blogCharset = comment.blogCharset
userRole = AkismetComment.ADMIN_ROLE
recheckReason = comment.recheckReason
isTest = true
}
}
@JvmStatic
@BeforeClass
fun beforeClass() {
if (isFirstRun.getAndSet(false)) {
with(akismet.logger) {
addHandler(ConsoleHandler().apply { level = Level.FINE })
level = Level.FINE
}
}
akismet.logger.info(comment.toString())
akismet.logger.info(mockComment.toJson())
}
private fun getKey(key: String): String {
return System.getenv(key)?.takeUnless { it.isBlank() }
?: loadPropertyValue(key)
}
private fun getMockRequest(): HttpServletRequest {
val request = Mockito.mock(HttpServletRequest::class.java)
with(request) {
whenever(remoteAddr).thenReturn(comment.userIp)
whenever(requestURI).thenReturn("/blog/post=1")
whenever(getHeader("referer")).thenReturn(REFERER)
whenever(getHeader("Cookie")).thenReturn("name=value; name2=value2; name3=value3")
whenever(getHeader("User-Agent")).thenReturn(comment.userAgent)
whenever(getHeader("Accept-Encoding")).thenReturn("gzip")
whenever(headerNames).thenReturn(
Collections.enumeration(listOf("User-Agent", "referer", "Cookie", "Accept-Encoding", "Null"))
)
}
return request
}
private fun loadPropertyValue(key: String): String {
return File("local.properties")
.takeIf { it.exists() }
?.let { file ->
FileInputStream(file).use { fis ->
Properties().apply { load(fis) }.getProperty(key, "")
}
}.orEmpty()
}
}
@Nested
@DisplayName("Comment Tests")
inner class CommentTests {
@Test
fun checkComment() {
with(akismet) {
assertFalse(checkComment(comment), "checkComment(admin)")
assertThat(akismet::response).isEqualTo("false")
comment.userRole = ""
assertTrue(checkComment(comment), "checkComment()")
assertThat(akismet::response).isEqualTo("true")
assertFalse(checkComment(mockComment), "checkComment(mock)")
assertThat(akismet::response).isEqualTo("false")
mockComment.userRole = ""
assertTrue(checkComment(mockComment), "checkComment(mock)")
assertThat(akismet::response).isEqualTo("true")
assertThat(akismet::httpStatusCode).isEqualTo(200)
comment.userRole = AkismetComment.ADMIN_ROLE
}
}
@Test
fun emptyComment() {
assertThrows(
java.lang.IllegalArgumentException::class.java
) { akismet.checkComment(AkismetComment("", "")) }
val empty = AkismetComment("", "")
assertThat(empty, "AkismetComment(empty)").all {
prop(AkismetComment::isTest).isFalse()
prop(AkismetComment::referrer).isEqualTo("")
prop(AkismetComment::permalink).isEqualTo("")
prop(AkismetComment::type).isEqualTo(CommentType.NONE)
prop(AkismetComment::authorEmail).isEqualTo("")
prop(AkismetComment::author).isEqualTo("")
prop(AkismetComment::authorUrl).isEqualTo("")
prop(AkismetComment::content).isEqualTo("")
prop(AkismetComment::dateGmt).isEqualTo("")
prop(AkismetComment::postModifiedGmt).isEqualTo("")
prop(AkismetComment::blogLang).isEqualTo("")
prop(AkismetComment::blogCharset).isEqualTo("")
prop(AkismetComment::userRole).isEqualTo("")
prop(AkismetComment::recheckReason).isEqualTo("")
prop(AkismetComment::serverEnv).size().isEqualTo(0)
}
with(receiver = empty) {
for (s in listOf("test", "", null)) {
referrer = s
permalink = s
if (s != null) type = CommentType(s)
authorEmail = s
author = s
authorUrl = s
content = s
dateGmt = s
postModifiedGmt = s
blogLang = s
blogCharset = s
userRole = s
recheckReason = s
val expected = if (s.isNullOrEmpty()) "" else s
assertThat(empty, "AkismetComment($s)").all {
prop(AkismetComment::referrer).isEqualTo(expected)
prop(AkismetComment::permalink).isEqualTo(expected)
prop(AkismetComment::type).isEqualTo(CommentType(expected))
prop(AkismetComment::authorEmail).isEqualTo(expected)
prop(AkismetComment::author).isEqualTo(expected)
prop(AkismetComment::authorUrl).isEqualTo(expected)
prop(AkismetComment::content).isEqualTo(expected)
prop(AkismetComment::dateGmt).isEqualTo(expected)
prop(AkismetComment::postModifiedGmt).isEqualTo(expected)
prop(AkismetComment::blogLang).isEqualTo(expected)
prop(AkismetComment::blogCharset).isEqualTo(expected)
prop(AkismetComment::userRole).isEqualTo(expected)
prop(AkismetComment::recheckReason).isEqualTo(expected)
prop(AkismetComment::serverEnv).size().isEqualTo(0)
}
}
}
}
@Test
fun mockComment() {
assertThat(mockComment, "mockComment").all {
prop(AkismetComment::userIp).isEqualTo(comment.userIp)
prop(AkismetComment::userAgent).isEqualTo(comment.userAgent)
prop(AkismetComment::referrer).isEqualTo(comment.referrer)
prop(AkismetComment::serverEnv).all {
key("HTTP_ACCEPT_ENCODING").isEqualTo("gzip")
key("REMOTE_ADDR").isEqualTo(comment.userIp)
key("HTTP_NULL").isEmpty()
size().isEqualTo(6)
}
}
}
private val apiKey = TestUtils.getKey("AKISMET_API_KEY")
private val blog = TestUtils.getKey("AKISMET_BLOG")
}
@Nested
@DisplayName("Constructor Tests")
inner class ConstructorTests {
@Test
fun apiKeyTooLong() {
assertThrows(
IllegalArgumentException::class.java
) {
Akismet("123456789 12")
}
}
@Test
fun apiKeyTooShort() {
assertThrows(
IllegalArgumentException::class.java
) {
Akismet("1234")
}
}
@Test
fun invalidKeyAndBlog() {
assertThrows(
IllegalArgumentException::class.java
) {
Akismet("1234", "foo")
}
}
@Test
fun noApiKey() {
fun `Constructor with API key arg empty`() {
assertThrows(
IllegalArgumentException::class.java
) {
@ -321,64 +82,159 @@ class AkismetTests {
}
@Test
fun noBlog() {
fun `Constructor with API key arg too long`() {
assertThrows(
IllegalArgumentException::class.java
) {
Akismet("123456789 12")
}
}
@Test
fun `Constructor with API key arg too short`() {
assertThrows(
IllegalArgumentException::class.java
) {
Akismet("1234")
}
}
@Test
fun `Constructor with empty blog arg`() {
assertThrows(
IllegalArgumentException::class.java
) {
Akismet("123456789012", "")
}
}
}
@Test
fun dateToGmt() {
val localDateTime = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault())
val utcDate = Akismet.dateToGmt(date)
assertEquals(Akismet.dateToGmt(localDateTime), utcDate, "dateGmt(localDateTime)")
assertThat(comment::dateGmt).isEqualTo(utcDate)
@Test
fun `Constructor with invalid key and blog args`() {
assertThrows(
IllegalArgumentException::class.java
) {
Akismet("1234", "foo")
}
}
}
@Nested
@DisplayName("JSON Comment Tests")
inner class JsonCommentTest {
@DisplayName("Date Conversion Tests")
inner class DateConversionTests {
val sampleDate: ZonedDateTime = LocalDateTime.of(1997, 8, 29, 2, 0, 0)
.atZone(ZoneId.of("America/New_York"))
@Test
fun jsonCommentEqualHashCode() {
val jsonComment = Akismet.jsonComment(mockComment.toJson())
assertEquals(
jsonComment.hashCode(),
mockComment.hashCode(),
"jsonComment.hashCode = mockComment.hashcode"
)
fun `Date should convert correctly to GMT string`() {
val date = Date.from(sampleDate.toInstant())
val result = Akismet.dateToGmt(date)
assertEquals("1997-08-28T23:00:00-07:00", result)
}
@Test
fun jsonCommentEqualsMockComment() {
val jsonComment = Akismet.jsonComment(mockComment.toJson())
assertEquals(jsonComment, mockComment, "jsonComment = mockComment")
fun `LocalDateTime should convert correctly to GMT string`() {
val result = Akismet.dateToGmt(sampleDate.toLocalDateTime())
assertEquals("1997-08-29T02:00:00-07:00", result)
}
}
@Nested
@DisplayName("JSON Deserialization Tests")
inner class JsonDeserializationTests {
@Test
fun `Validate JSON deserialization`() {
val config = CommentConfig.Builder("127.0.0.1", "Mozilla/5.0")
.referrer("https://example.com")
.type(CommentType.COMMENT)
.author("John Doe")
.authorEmail("john.doe@example.com")
.authorUrl("https://johndoe.com")
.content("This is a comment")
.dateGmt("2023-10-20T10:20:30Z")
.postModifiedGmt("2023-10-20T11:00:00Z")
.blogLang("en")
.blogCharset("UTF-8")
.userRole("administrator")
.isTest(true)
.recheckReason("Check the spam detection")
.serverEnv(mapOf("key1" to "value1", "key2" to "value2"))
.build()
val validJson = AkismetComment(config).toJson();
val comment = jsonComment(validJson)
Assertions.assertEquals("127.0.0.1", comment.userIp)
Assertions.assertEquals("Mozilla/5.0", comment.userAgent)
Assertions.assertEquals("https://example.com", comment.referrer)
Assertions.assertEquals("comment", comment.type.value)
Assertions.assertEquals("John Doe", comment.author)
Assertions.assertEquals("john.doe@example.com", comment.authorEmail)
Assertions.assertEquals("https://johndoe.com", comment.authorUrl)
Assertions.assertEquals("This is a comment", comment.content)
Assertions.assertEquals("2023-10-20T10:20:30Z", comment.dateGmt)
Assertions.assertEquals("2023-10-20T11:00:00Z", comment.postModifiedGmt)
Assertions.assertEquals("en", comment.blogLang)
Assertions.assertEquals("UTF-8", comment.blogCharset)
Assertions.assertEquals("administrator", comment.userRole)
Assertions.assertTrue(comment.isTest)
Assertions.assertEquals("Check the spam detection", comment.recheckReason)
Assertions.assertEquals(mapOf("key1" to "value1", "key2" to "value2"), comment.serverEnv)
}
@Test
fun jsonCommentNotEqualsComment() {
val jsonComment = Akismet.jsonComment(mockComment.toJson())
assertNotEquals(jsonComment, comment, "json")
assertNotEquals(
jsonComment.hashCode(),
comment.hashCode(),
"jsonComment.hashCode != mockComment.hashcode"
)
fun `Invalid JSON deserialization`() {
val invalidJson = """
{
"userIp": "127.0.0.1",
"userAgent": "Mozilla/5.0"
// Missing closing brace
""".trimIndent()
val exception = Assertions.assertThrows(SerializationException::class.java) {
jsonComment(invalidJson)
}
Assertions.assertTrue(exception.message?.contains("Unexpected JSON token") == true)
}
@Test
fun jsonCommentNotEqualsMockComment() {
val jsonComment = Akismet.jsonComment(mockComment.toJson())
jsonComment.recheckReason = ""
assertNotEquals(jsonComment, mockComment, "jsonComment != jsonComment")
@OptIn(ExperimentalSerializationApi::class)
fun `Empty JSON deserialization`() {
val emptyJson = "{}"
Assertions.assertThrows(MissingFieldException::class.java) {
jsonComment(emptyJson)
}
}
@Test
fun jsonCommentNotEqualsThis() {
Akismet.jsonComment(mockComment.toJson())
assertThat(this, "this != comment").isNotEqualTo(comment)
@OptIn(ExperimentalSerializationApi::class)
fun `JSON deserialization with missing mandatory fields`() {
val partialJson = """
{
"userIp": "127.0.0.1"
}
""".trimIndent()
Assertions.assertThrows(MissingFieldException::class.java) {
jsonComment(partialJson)
}
}
@Test
fun `JSON deserialization with unexpected fields`() {
val extraFieldJson = """
{
"userIp": "127.0.0.1",
"userAgent": "Mozilla/5.0",
"extraField": "unexpected"
}
""".trimIndent()
val comment = jsonComment(extraFieldJson)
Assertions.assertEquals("127.0.0.1", comment.userIp)
Assertions.assertEquals("Mozilla/5.0", comment.userAgent)
}
}
@ -386,30 +242,14 @@ class AkismetTests {
@DisplayName("Response Tests")
inner class ResponseTests {
@Test
fun emptyResponse() {
assertTrue(
akismet.executeMethod(
"https://postman-echo.com/status/200".toHttpUrl(), emptyFormBody, true
)
)
var expected = "{\n \"status\": 200\n}"
assertThat(akismet, "executeMethod(200)").all {
prop(Akismet::response).isEqualTo(expected)
prop(Akismet::errorMessage).contains(expected)
}
akismet.reset()
assertThat(akismet, "akismet.reset()").all {
prop(Akismet::httpStatusCode).isEqualTo(0)
prop(Akismet::errorMessage).isEmpty()
}
fun `Handle blank response`() {
val akismet = Akismet(apiKey)
assertTrue(
akismet.executeMethod(
"https://erik.thauvin.net/blank.html".toHttpUrl(), emptyFormBody, true
)
)
expected = ""
val expected = ""
assertThat(akismet, "executeMethod(blank)").all {
prop(Akismet::response).isEqualTo(expected)
prop(Akismet::errorMessage).contains("blank")
@ -417,12 +257,17 @@ class AkismetTests {
}
@Test
fun executeMethod() {
fun `Handle debug help header`() {
val akismet = Akismet(apiKey)
akismet.executeMethod(
"https://$apiKey.rest.akismet.com/1.1/comment-check".toHttpUrl(),
FormBody.Builder().apply { add("is_test", "1") }.build()
)
assertThat(akismet::debugHelp).isNotEmpty()
assertThat(akismet, "x-akismet-debug-help").all {
prop(Akismet::httpStatusCode).isEqualTo(200)
prop(Akismet::debugHelp).isEqualTo("Empty \"blog\" value")
}
akismet.reset()
assertThat(akismet, "akismet.reset()").all {
@ -433,20 +278,44 @@ class AkismetTests {
}
@Test
fun invalidApi() {
fun `Handle invalid response`() {
val akismet = Akismet(apiKey)
assertTrue(
akismet.executeMethod(
"https://postman-echo.com/status/200".toHttpUrl(), emptyFormBody, true
)
)
val expected = "{\n \"status\": 200\n}"
assertThat(akismet, "executeMethod(200)").all {
prop(Akismet::response).isEqualTo(expected)
prop(Akismet::errorMessage).contains(expected)
}
akismet.reset()
assertThat(akismet, "akismet.reset()").all {
prop(Akismet::httpStatusCode).isEqualTo(0)
prop(Akismet::errorMessage).isEmpty()
}
}
@Test
fun `Handle IO error`() {
val akismet = Akismet(apiKey)
akismet.executeMethod("https://www.foobarxyz.com".toHttpUrl(), emptyFormBody)
assertThat(akismet).prop(Akismet::errorMessage).contains("IO error")
}
@Test
fun `Handle invalid API URL`() {
val akismet = Akismet(apiKey)
assertThrows(
java.lang.IllegalArgumentException::class.java
) { akismet.executeMethod("https://.com".toHttpUrl(), emptyFormBody) }
}
@Test
fun ioError() {
akismet.executeMethod("https://www.foobarxyz.com".toHttpUrl(), emptyFormBody)
assertThat(akismet::errorMessage).contains("IO error")
}
@Test
fun proTip() {
fun `Handle pro tip header`() {
val akismet = Akismet(apiKey)
assertFalse(
akismet.executeMethod(
"https://postman-echo.com/response-headers?x-akismet-pro-tip=discard".toHttpUrl(),
@ -465,90 +334,70 @@ class AkismetTests {
prop(Akismet::httpStatusCode).isEqualTo(0)
}
}
}
@Nested
@DisplayName("Submit Test")
inner class SubmitTests {
@Test
fun submitHam() {
assertTrue(akismet.submitHam(comment), "submitHam")
}
@Test
fun submitHamMocked() {
assertTrue(akismet.submitHam(mockComment), "submitHam(mock)")
}
@Nested
@DisplayName("Validation Tests")
inner class ValidationTests {
@Test
fun `Validate api key`() {
val akismet = Akismet(apiKey, blog)
assertThat(akismet, "akismet").all {
prop(Akismet::isVerifiedKey).isFalse()
prop(Akismet::verifyKey).isTrue()
prop(Akismet::response).isEqualTo("valid")
prop(Akismet::isVerifiedKey).isTrue()
}
@Test
fun submitSpam() {
assertTrue(akismet.submitSpam(comment), "submitHam")
}
akismet.reset()
assertThat(akismet, "akismet.reset()").all {
prop(Akismet::isVerifiedKey).isFalse()
prop(Akismet::response).isEmpty()
prop(Akismet::httpStatusCode).isEqualTo(0)
}
@Test
fun submitSpamMocked() {
assertTrue(akismet.submitSpam(mockComment), "submitHam(mock)")
}
}
@Nested
@DisplayName("User Agent Tests")
inner class UserAgentTests {
val libAgent = "${GeneratedVersion.PROJECT}/${GeneratedVersion.VERSION}"
@Test
fun userAgentCustom() {
akismet.appUserAgent = "My App/1.0"
assertEquals(
akismet.buildUserAgent(),
"${akismet.appUserAgent} | $libAgent",
"buildUserAgent(My App/1.0)"
)
}
@Test
fun userAgentDefault() {
assertEquals(akismet.buildUserAgent(), libAgent, "buildUserAgent($libAgent)")
}
}
@Nested
@DisplayName("Validation Tests")
inner class ValidationTests {
@Test
fun blogProperty() {
assertThrows(IllegalArgumentException::class.java) {
akismet.blog = ""
assertThat(Akismet("123456789012"), "akismet(123456789012)")
.prop(Akismet::verifyKey)
.isFalse()
}
assertThat(akismet::blog).isEqualTo(blog)
}
@Test
fun `Validate blog property`() {
val akismet = Akismet(apiKey, blog)
assertThrows(IllegalArgumentException::class.java) {
akismet.blog = ""
}
@Test
fun validateConfig() {
assertThat(AkismetComment(config)).isEqualTo(comment)
}
@Test
fun verifyKey() {
assertThat(akismet, "akismet").all {
prop(Akismet::isVerifiedKey).isFalse()
prop(Akismet::verifyKey).isTrue()
prop(Akismet::response).isEqualTo("valid")
prop(Akismet::isVerifiedKey).isTrue()
assertThat(akismet).prop(Akismet::blog).isEqualTo(blog)
}
akismet.reset()
assertThat(akismet, "akismet.reset()").all {
prop(Akismet::isVerifiedKey).isFalse()
prop(Akismet::response).isEmpty()
prop(Akismet::httpStatusCode).isEqualTo(0)
}
assertThat(Akismet("123456789012"), "akismet(123456789012)")
.prop(Akismet::verifyKey)
.isFalse()
@Nested
@DisplayName("User Agent Validation Tests")
inner class UserAgentValidationTests {
val libAgent = "${GeneratedVersion.PROJECT}/${GeneratedVersion.VERSION}"
@Test
fun `Validate custom user agent`() {
val akismet = Akismet(apiKey)
akismet.appUserAgent = "My App/1.0"
assertEquals(
"${akismet.appUserAgent} | $libAgent",
akismet.buildUserAgent(),
"buildUserAgent(My App/1.0)"
)
}
@Test
fun `Validate default user agent`() {
val akismet = Akismet(apiKey)
assertEquals(
libAgent, akismet.buildUserAgent(),
"buildUserAgent($libAgent)"
)
}
}
}
}
}

View file

@ -0,0 +1,51 @@
/*
* BeforeAllTests.kt
*
* Copyright 2019-2025 Erik C. Thauvin (erik@thauvin.net)
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* Neither the name of this project nor the names of its contributors may be
* used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.thauvin.erik.akismet
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.ExtensionContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.ConsoleHandler
import java.util.logging.Level
class BeforeAllTests : BeforeAllCallback {
private val isFirstTime: AtomicBoolean = AtomicBoolean(true)
override fun beforeAll(context: ExtensionContext?) {
if (isFirstTime.getAndSet(false)) {
with(Akismet.logger) {
addHandler(ConsoleHandler().apply { level = Level.FINE })
level = Level.FINE
}
}
}
}

View file

@ -39,7 +39,7 @@ import kotlin.test.assertTrue
class CommentConfigTests {
@Test
fun `test default optional fields`() {
fun `Default optional fields`() {
val commentConfig = CommentConfig.Builder("192.168.0.1", "DefaultAgent").build()
assertEquals("", commentConfig.referrer)
@ -60,7 +60,7 @@ class CommentConfigTests {
}
@Test
fun `test empty server environment`() {
fun `Empty server environment`() {
val commentConfig = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
.serverEnv(emptyMap())
.build()
@ -69,7 +69,7 @@ class CommentConfigTests {
@Test
fun `test invalid inputs for mandatory fields`() {
fun `Invalid inputs for mandatory fields`() {
try {
CommentConfig.Builder("", "UserAgent").build()
} catch (e: IllegalArgumentException) {
@ -88,7 +88,7 @@ class CommentConfigTests {
@DisplayName("Builder Tests")
inner class BuilderTests {
@Test
fun `test builder with all optional fields`() {
fun `Builder with all optional fields`() {
val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
.referrer("http://example.com")
.permalink("http://example.com/post")
@ -128,7 +128,7 @@ class CommentConfigTests {
}
@Test
fun `test builder with mandatory fields only`() {
fun `Builder with mandatory fields only`() {
val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
val commentConfig = builder.build()
@ -152,7 +152,7 @@ class CommentConfigTests {
}
@Test
fun `test builder with modified mandatory fields`() {
fun `Builder with modified mandatory fields`() {
val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
.userIp("192.168.1.1")
.userAgent("ModifiedUserAgent")

View file

@ -36,67 +36,67 @@ import org.junit.jupiter.api.Test
class CommentTypeTest {
@Test
fun `verify BLOG_POST value`() {
fun `Verify BLOG_POST value`() {
val commentType = CommentType.BLOG_POST
assertEquals("blog-post", commentType.value)
}
@Test
fun `verify COMMENT value`() {
fun `Verify COMMENT value`() {
val commentType = CommentType.COMMENT
assertEquals("comment", commentType.value)
}
@Test
fun `verify CONTACT_FORM value`() {
fun `Verify CONTACT_FORM value`() {
val commentType = CommentType.CONTACT_FORM
assertEquals("contact-form", commentType.value)
}
@Test
fun `verify FORUM_POST value`() {
fun `Verify FORUM_POST value`() {
val commentType = CommentType.FORUM_POST
assertEquals("forum-post", commentType.value)
}
@Test
fun `verify MESSAGE value`() {
fun `Verify MESSAGE value`() {
val commentType = CommentType.MESSAGE
assertEquals("message", commentType.value)
}
@Test
fun `verify NONE value`() {
fun `Verify NONE value`() {
val commentType = CommentType.NONE
assertEquals("", commentType.value)
}
@Test
fun `verify PINGBACK value`() {
fun `Verify PINGBACK value`() {
val commentType = CommentType.PINGBACK
assertEquals("pingback", commentType.value)
}
@Test
fun `verify REPLY value`() {
fun `Verify REPLY value`() {
val commentType = CommentType.REPLY
assertEquals("reply", commentType.value)
}
@Test
fun `verify SIGNUP value`() {
fun `Verify SIGNUP value`() {
val commentType = CommentType.SIGNUP
assertEquals("signup", commentType.value)
}
@Test
fun `verify TRACKBACK value`() {
fun `Verify TRACKBACK value`() {
val commentType = CommentType.TRACKBACK
assertEquals("trackback", commentType.value)
}
@Test
fun `verify TWEET value`() {
fun `Verify TWEET value`() {
val commentType = CommentType.TWEET
assertEquals("tweet", commentType.value)
}

View file

@ -0,0 +1,52 @@
/*
* TestUtils.kt
*
* Copyright 2019-2025 Erik C. Thauvin (erik@thauvin.net)
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* Neither the name of this project nor the names of its contributors may be
* used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.thauvin.erik.akismet
import java.io.File
import java.io.FileInputStream
import java.util.Properties
object TestUtils {
fun getKey(key: String): String {
return System.getenv(key)?.takeUnless { it.isBlank() } ?: loadPropertyValue(key)
}
private fun loadPropertyValue(key: String): String {
return File("local.properties")
.takeIf { it.exists() }
?.let { file ->
FileInputStream(file).use { fis ->
Properties().apply { load(fis) }.getProperty(key, "")
}
}.orEmpty()
}
}