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: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>ReturnCount:AkismetTest.kt$fun getKey(key: String): String</ID>
<ID>TooManyFunctions:CommentConfig.kt$CommentConfig$Builder</ID> <ID>TooManyFunctions:CommentConfig.kt$CommentConfig$Builder</ID>
<ID>WildcardImport:AkismetCommentTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:AkismetTests.kt$import assertk.assertions.*</ID> <ID>WildcardImport:AkismetTests.kt$import assertk.assertions.*</ID>
</CurrentIssues> </CurrentIssues>
</SmellBaseline> </SmellBaseline>

View file

@ -32,8 +32,10 @@
package net.thauvin.erik.akismet package net.thauvin.erik.akismet
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
private fun String?.ifNull() = this ?: "" 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 * @param userAgent User agent string of the web browser submitting the comment
*/ */
@Serializable @Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
open class AkismetComment(val userIp: String, val userAgent: String) { open class AkismetComment(val userIp: String, val userAgent: String) {
companion object { 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.all
import assertk.assertThat import assertk.assertThat
import assertk.assertions.* 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.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertThrows 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.DisplayName
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.kotlin.whenever
import java.io.File
import java.io.FileInputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.* 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.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.text.contains
/** /**
* [Akismet] Tests * [Akismet] Tests
* *
* `AKISMET_API_KEY` and `AKISMET_BLOG` should be set in env vars or `local.properties` * `AKISMET_API_KEY` and `AKISMET_BLOG` should be set in env vars or `local.properties`
*/ */
@ExtendWith(BeforeAllTests::class)
class AkismetTests { class AkismetTests {
private val emptyFormBody = FormBody.Builder().build() private val emptyFormBody = FormBody.Builder().build()
companion object { companion object {
private const val REFERER = "https://www.google.com" private val apiKey = TestUtils.getKey("AKISMET_API_KEY")
private val apiKey = getKey("AKISMET_API_KEY") private val blog = TestUtils.getKey("AKISMET_BLOG")
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)
}
}
}
} }
@Nested @Nested
@DisplayName("Constructor Tests") @DisplayName("Constructor Tests")
inner class ConstructorTests { inner class ConstructorTests {
@Test @Test
fun apiKeyTooLong() { fun `Constructor with API key arg empty`() {
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() {
assertThrows( assertThrows(
IllegalArgumentException::class.java IllegalArgumentException::class.java
) { ) {
@ -321,64 +82,159 @@ class AkismetTests {
} }
@Test @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( assertThrows(
IllegalArgumentException::class.java IllegalArgumentException::class.java
) { ) {
Akismet("123456789012", "") Akismet("123456789012", "")
} }
} }
}
@Test @Test
fun dateToGmt() { fun `Constructor with invalid key and blog args`() {
val localDateTime = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()) assertThrows(
val utcDate = Akismet.dateToGmt(date) IllegalArgumentException::class.java
assertEquals(Akismet.dateToGmt(localDateTime), utcDate, "dateGmt(localDateTime)") ) {
assertThat(comment::dateGmt).isEqualTo(utcDate) Akismet("1234", "foo")
}
}
} }
@Nested @Nested
@DisplayName("JSON Comment Tests") @DisplayName("Date Conversion Tests")
inner class JsonCommentTest { inner class DateConversionTests {
val sampleDate: ZonedDateTime = LocalDateTime.of(1997, 8, 29, 2, 0, 0)
.atZone(ZoneId.of("America/New_York"))
@Test @Test
fun jsonCommentEqualHashCode() { fun `Date should convert correctly to GMT string`() {
val jsonComment = Akismet.jsonComment(mockComment.toJson()) val date = Date.from(sampleDate.toInstant())
assertEquals( val result = Akismet.dateToGmt(date)
jsonComment.hashCode(), assertEquals("1997-08-28T23:00:00-07:00", result)
mockComment.hashCode(),
"jsonComment.hashCode = mockComment.hashcode"
)
} }
@Test @Test
fun jsonCommentEqualsMockComment() { fun `LocalDateTime should convert correctly to GMT string`() {
val jsonComment = Akismet.jsonComment(mockComment.toJson()) val result = Akismet.dateToGmt(sampleDate.toLocalDateTime())
assertEquals(jsonComment, mockComment, "jsonComment = mockComment") 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 @Test
fun jsonCommentNotEqualsComment() { fun `Invalid JSON deserialization`() {
val jsonComment = Akismet.jsonComment(mockComment.toJson()) val invalidJson = """
assertNotEquals(jsonComment, comment, "json") {
assertNotEquals( "userIp": "127.0.0.1",
jsonComment.hashCode(), "userAgent": "Mozilla/5.0"
comment.hashCode(), // Missing closing brace
"jsonComment.hashCode != mockComment.hashcode" """.trimIndent()
)
val exception = Assertions.assertThrows(SerializationException::class.java) {
jsonComment(invalidJson)
}
Assertions.assertTrue(exception.message?.contains("Unexpected JSON token") == true)
} }
@Test @Test
fun jsonCommentNotEqualsMockComment() { @OptIn(ExperimentalSerializationApi::class)
val jsonComment = Akismet.jsonComment(mockComment.toJson()) fun `Empty JSON deserialization`() {
jsonComment.recheckReason = "" val emptyJson = "{}"
assertNotEquals(jsonComment, mockComment, "jsonComment != jsonComment")
Assertions.assertThrows(MissingFieldException::class.java) {
jsonComment(emptyJson)
}
} }
@Test @Test
fun jsonCommentNotEqualsThis() { @OptIn(ExperimentalSerializationApi::class)
Akismet.jsonComment(mockComment.toJson()) fun `JSON deserialization with missing mandatory fields`() {
assertThat(this, "this != comment").isNotEqualTo(comment) 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") @DisplayName("Response Tests")
inner class ResponseTests { inner class ResponseTests {
@Test @Test
fun emptyResponse() { fun `Handle blank response`() {
assertTrue( val akismet = Akismet(apiKey)
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()
}
assertTrue( assertTrue(
akismet.executeMethod( akismet.executeMethod(
"https://erik.thauvin.net/blank.html".toHttpUrl(), emptyFormBody, true "https://erik.thauvin.net/blank.html".toHttpUrl(), emptyFormBody, true
) )
) )
expected = "" val expected = ""
assertThat(akismet, "executeMethod(blank)").all { assertThat(akismet, "executeMethod(blank)").all {
prop(Akismet::response).isEqualTo(expected) prop(Akismet::response).isEqualTo(expected)
prop(Akismet::errorMessage).contains("blank") prop(Akismet::errorMessage).contains("blank")
@ -417,12 +257,17 @@ class AkismetTests {
} }
@Test @Test
fun executeMethod() { fun `Handle debug help header`() {
val akismet = Akismet(apiKey)
akismet.executeMethod( akismet.executeMethod(
"https://$apiKey.rest.akismet.com/1.1/comment-check".toHttpUrl(), "https://$apiKey.rest.akismet.com/1.1/comment-check".toHttpUrl(),
FormBody.Builder().apply { add("is_test", "1") }.build() 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() akismet.reset()
assertThat(akismet, "akismet.reset()").all { assertThat(akismet, "akismet.reset()").all {
@ -433,20 +278,44 @@ class AkismetTests {
} }
@Test @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( assertThrows(
java.lang.IllegalArgumentException::class.java java.lang.IllegalArgumentException::class.java
) { akismet.executeMethod("https://.com".toHttpUrl(), emptyFormBody) } ) { akismet.executeMethod("https://.com".toHttpUrl(), emptyFormBody) }
} }
@Test @Test
fun ioError() { fun `Handle pro tip header`() {
akismet.executeMethod("https://www.foobarxyz.com".toHttpUrl(), emptyFormBody) val akismet = Akismet(apiKey)
assertThat(akismet::errorMessage).contains("IO error")
}
@Test
fun proTip() {
assertFalse( assertFalse(
akismet.executeMethod( akismet.executeMethod(
"https://postman-echo.com/response-headers?x-akismet-pro-tip=discard".toHttpUrl(), "https://postman-echo.com/response-headers?x-akismet-pro-tip=discard".toHttpUrl(),
@ -465,73 +334,14 @@ class AkismetTests {
prop(Akismet::httpStatusCode).isEqualTo(0) 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)")
}
@Test
fun submitSpam() {
assertTrue(akismet.submitSpam(comment), "submitHam")
}
@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 @Nested
@DisplayName("Validation Tests") @DisplayName("Validation Tests")
inner class ValidationTests { inner class ValidationTests {
@Test @Test
fun blogProperty() { fun `Validate api key`() {
assertThrows(IllegalArgumentException::class.java) { val akismet = Akismet(apiKey, blog)
akismet.blog = ""
}
assertThat(akismet::blog).isEqualTo(blog)
}
@Test
fun validateConfig() {
assertThat(AkismetComment(config)).isEqualTo(comment)
}
@Test
fun verifyKey() {
assertThat(akismet, "akismet").all { assertThat(akismet, "akismet").all {
prop(Akismet::isVerifiedKey).isFalse() prop(Akismet::isVerifiedKey).isFalse()
prop(Akismet::verifyKey).isTrue() prop(Akismet::verifyKey).isTrue()
@ -550,5 +360,44 @@ class AkismetTests {
.prop(Akismet::verifyKey) .prop(Akismet::verifyKey)
.isFalse() .isFalse()
} }
@Test
fun `Validate blog property`() {
val akismet = Akismet(apiKey, blog)
assertThrows(IllegalArgumentException::class.java) {
akismet.blog = ""
}
assertThat(akismet).prop(Akismet::blog).isEqualTo(blog)
}
@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 { class CommentConfigTests {
@Test @Test
fun `test default optional fields`() { fun `Default optional fields`() {
val commentConfig = CommentConfig.Builder("192.168.0.1", "DefaultAgent").build() val commentConfig = CommentConfig.Builder("192.168.0.1", "DefaultAgent").build()
assertEquals("", commentConfig.referrer) assertEquals("", commentConfig.referrer)
@ -60,7 +60,7 @@ class CommentConfigTests {
} }
@Test @Test
fun `test empty server environment`() { fun `Empty server environment`() {
val commentConfig = CommentConfig.Builder("127.0.0.1", "TestUserAgent") val commentConfig = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
.serverEnv(emptyMap()) .serverEnv(emptyMap())
.build() .build()
@ -69,7 +69,7 @@ class CommentConfigTests {
@Test @Test
fun `test invalid inputs for mandatory fields`() { fun `Invalid inputs for mandatory fields`() {
try { try {
CommentConfig.Builder("", "UserAgent").build() CommentConfig.Builder("", "UserAgent").build()
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
@ -88,7 +88,7 @@ class CommentConfigTests {
@DisplayName("Builder Tests") @DisplayName("Builder Tests")
inner class BuilderTests { inner class BuilderTests {
@Test @Test
fun `test builder with all optional fields`() { fun `Builder with all optional fields`() {
val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent") val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
.referrer("http://example.com") .referrer("http://example.com")
.permalink("http://example.com/post") .permalink("http://example.com/post")
@ -128,7 +128,7 @@ class CommentConfigTests {
} }
@Test @Test
fun `test builder with mandatory fields only`() { fun `Builder with mandatory fields only`() {
val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent") val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
val commentConfig = builder.build() val commentConfig = builder.build()
@ -152,7 +152,7 @@ class CommentConfigTests {
} }
@Test @Test
fun `test builder with modified mandatory fields`() { fun `Builder with modified mandatory fields`() {
val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent") val builder = CommentConfig.Builder("127.0.0.1", "TestUserAgent")
.userIp("192.168.1.1") .userIp("192.168.1.1")
.userAgent("ModifiedUserAgent") .userAgent("ModifiedUserAgent")

View file

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