diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..d91f848 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 078daff..da48b7c 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -12,6 +12,7 @@ ReturnCount:Akismet.kt$Akismet$@JvmOverloads fun executeMethod(apiUrl: HttpUrl, formBody: FormBody, trueOnError: Boolean = false): Boolean ReturnCount:AkismetTest.kt$fun getKey(key: String): String TooManyFunctions:CommentConfig.kt$CommentConfig$Builder + WildcardImport:AkismetCommentTest.kt$import assertk.assertions.* WildcardImport:AkismetTests.kt$import assertk.assertions.* diff --git a/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt b/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt index 218cb67..dba58c0 100644 --- a/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt +++ b/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt @@ -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 { /** diff --git a/src/test/kotlin/net/thauvin/erik/akismet/AkismetCommentTest.kt b/src/test/kotlin/net/thauvin/erik/akismet/AkismetCommentTest.kt new file mode 100644 index 0000000..8021f1d --- /dev/null +++ b/src/test/kotlin/net/thauvin/erik/akismet/AkismetCommentTest.kt @@ -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") + } + } +} diff --git a/src/test/kotlin/net/thauvin/erik/akismet/AkismetTests.kt b/src/test/kotlin/net/thauvin/erik/akismet/AkismetTests.kt index 5a13186..07acdd3 100644 --- a/src/test/kotlin/net/thauvin/erik/akismet/AkismetTests.kt +++ b/src/test/kotlin/net/thauvin/erik/akismet/AkismetTests.kt @@ -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)" + ) + } + } } } } diff --git a/src/test/kotlin/net/thauvin/erik/akismet/BeforeAllTests.kt b/src/test/kotlin/net/thauvin/erik/akismet/BeforeAllTests.kt new file mode 100644 index 0000000..364e36d --- /dev/null +++ b/src/test/kotlin/net/thauvin/erik/akismet/BeforeAllTests.kt @@ -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 + } + } + } +} diff --git a/src/test/kotlin/net/thauvin/erik/akismet/CommentConfigTests.kt b/src/test/kotlin/net/thauvin/erik/akismet/CommentConfigTests.kt index 6f81ba5..e2abad4 100644 --- a/src/test/kotlin/net/thauvin/erik/akismet/CommentConfigTests.kt +++ b/src/test/kotlin/net/thauvin/erik/akismet/CommentConfigTests.kt @@ -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") diff --git a/src/test/kotlin/net/thauvin/erik/akismet/CommentTypeTest.kt b/src/test/kotlin/net/thauvin/erik/akismet/CommentTypeTest.kt index a3101cb..35bb4b4 100644 --- a/src/test/kotlin/net/thauvin/erik/akismet/CommentTypeTest.kt +++ b/src/test/kotlin/net/thauvin/erik/akismet/CommentTypeTest.kt @@ -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) } diff --git a/src/test/kotlin/net/thauvin/erik/akismet/TestUtils.kt b/src/test/kotlin/net/thauvin/erik/akismet/TestUtils.kt new file mode 100644 index 0000000..e3a06a0 --- /dev/null +++ b/src/test/kotlin/net/thauvin/erik/akismet/TestUtils.kt @@ -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() + } +}