Files
coco 723ce1af5c a
2026-07-03 15:12:48 +08:00

425 lines
15 KiB
Kotlin

/*
* Copyright 2020 Shreyas Patil
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.shreyaspatil.noty.api
import dev.shreyaspatil.noty.api.exception.BadRequestException
import dev.shreyaspatil.noty.api.exception.FailureMessages
import dev.shreyaspatil.noty.api.model.request.AuthRequest
import dev.shreyaspatil.noty.api.model.request.NoteRequest
import dev.shreyaspatil.noty.api.model.request.PinRequest
import dev.shreyaspatil.noty.api.model.response.AuthResponse
import dev.shreyaspatil.noty.api.model.response.NoteResponse
import dev.shreyaspatil.noty.api.model.response.NotesResponse
import dev.shreyaspatil.noty.api.model.response.State
import dev.shreyaspatil.noty.api.testutils.toJson
import dev.shreyaspatil.noty.api.testutils.toModel
import dev.shreyaspatil.noty.application.testutils.*
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.AnnotationSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContainIgnoringCase
import io.kotest.matchers.string.shouldInclude
import io.ktor.config.*
import io.ktor.server.testing.*
import io.ktor.util.*
import org.testcontainers.containers.PostgreSQLContainer
import java.util.*
@Suppress("unused")
@KtorExperimentalAPI
class ApplicationTest : AnnotationSpec() {
private val sqlContainer = DatabaseContainer()
@BeforeClass
fun setup() {
sqlContainer.start()
}
@Test
fun whenPassedInvalidCredentials_responseStateShouldBeFailed() = testApp {
post("/auth/register", AuthRequest("test", "test").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.FAILED
body.token shouldBe null
}
}
@Test
fun whenRegistrationIsSuccessful_shouldBeAbleToLogin() = testApp {
post("/auth/register", AuthRequest("test", "test1234").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.SUCCESS
body.message shouldBe "Registration successful"
body.token shouldNotBe null
}
post("/auth/login", AuthRequest("test", "test1234").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.SUCCESS
body.message shouldBe "Login successful"
body.token shouldNotBe null
}
}
@Test
fun whenRegistrationIsSuccessful_whenTryingToRegisterAgainWithSameUsername_shouldRespondFailedState() = testApp {
val authRequest = AuthRequest("iuser", "test1234").toJson()
post("/auth/register", authRequest)
// Try to create again. It should show username not available message.
post("/auth/register", authRequest).content.toModel<AuthResponse>().let { response ->
response.status shouldBe State.FAILED
response.token shouldBe null
response.message shouldBe "Username is not available"
}
}
@Test
fun whenCredentialsAreIllegal_shouldRespondFailedStateWithMessage() = testApp {
post("/auth/register", AuthRequest("test#", "test1234").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.FAILED
body.message shouldBe "No special characters allowed in username"
body.token shouldBe null
}
post("/auth/register", AuthRequest("hi", "hi@12341").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.FAILED
body.message shouldBe "Username should be of min 4 and max 30 character in length"
body.token shouldBe null
}
post("/auth/register", AuthRequest("test", "test").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.FAILED
body.message shouldBe "Password should be of min 8 and max 50 character in length"
body.token shouldBe null
}
post("/auth/login", AuthRequest("test#", "test1234").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.FAILED
body.message shouldBe "No special characters allowed in username"
body.token shouldBe null
}
post("/auth/login", AuthRequest("hi", "hi@12341").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.FAILED
body.message shouldBe "Username should be of min 4 and max 30 character in length"
body.token shouldBe null
}
post("/auth/login", AuthRequest("test", "test").toJson()).let { response ->
val body: AuthResponse = response.content.toModel()
body.status shouldBe State.FAILED
body.message shouldBe "Password should be of min 8 and max 50 character in length"
body.token shouldBe null
}
}
@Test
fun whenProvidedInvalidCredentials_shouldFailAndShowError() = testApp {
post(
"/auth/login",
AuthRequest("usernotexists", "test1234").toJson()
).content.toModel<AuthResponse>().let {
it.status shouldBe State.UNAUTHORIZED
it.message shouldBe "Invalid credentials"
it.token shouldBe null
}
}
@Test
fun authorizationKeyIsNotProvided_whenNotesAreRetrieved_shouldGetUnauthResponse() = testApp {
get("/notes").content shouldInclude "UNAUTHORIZED"
}
@Test
fun whenProvidedInvalidAuthBody_shouldThrowException() = testApp {
shouldThrow<BadRequestException> {
post("/auth/register", null)
}.let {
it.message shouldContainIgnoringCase FailureMessages.MESSAGE_MISSING_CREDENTIALS
}
shouldThrow<BadRequestException> {
post("/auth/login", null)
}.let {
it.message shouldContainIgnoringCase FailureMessages.MESSAGE_MISSING_CREDENTIALS
}
}
@Test
fun whenProvidedInvalidNoteBody_shouldThrowException() = testApp {
val token = post(
"/auth/register",
AuthRequest("newnoteuser", "newnoteuser1234").toJson()
).content.toModel<AuthResponse>().token
shouldThrow<BadRequestException> {
post("note/new", null, "Bearer $token")
}.let {
it.message shouldBe FailureMessages.MESSAGE_MISSING_NOTE_DETAILS
}
shouldThrow<BadRequestException> {
put("note/testnote", null, "Bearer $token")
}.let {
it.message shouldBe FailureMessages.MESSAGE_MISSING_NOTE_DETAILS
}
}
@Test
fun whenUserIsAuthenticated_shouldBeAbleToAddUpdateDeleteNotes() = testApp {
// Create user
val authResponse = post(
"/auth/register",
AuthRequest("notemaster", "notemaster").toJson()
).content.toModel<AuthResponse>()
// Create note
val newNoteJson = NoteRequest("Hey test", "This is note text").toJson()
val newNoteResponse = post(
"/note/new",
newNoteJson,
"Bearer ${authResponse.token}"
).content.toModel<NoteResponse>()
newNoteResponse.status shouldBe State.SUCCESS
newNoteResponse.noteId shouldNotBe null
// Get Notes
get("/notes", "Bearer ${authResponse.token}").content.toModel<NotesResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.notes shouldHaveSize 1
response.notes[0].let {
it.id shouldBe newNoteResponse.noteId
it.title shouldBe "Hey test"
it.note shouldBe "This is note text"
it.created shouldNotBe null
it.isPinned shouldBe false
}
}
// Update note
val updateRequest = NoteRequest("Hey update", "This is updated body").toJson()
put(
"/note/${newNoteResponse.noteId}",
updateRequest,
"Bearer ${authResponse.token}"
).content.toModel<NoteResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.noteId shouldNotBe null
}
// pin note
val pinRequest = PinRequest(isPinned = true).toJson()
patch(
"/note/${newNoteResponse.noteId}/pin",
pinRequest,
"Bearer ${authResponse.token}"
).content.toModel<NoteResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.noteId shouldNotBe null
}
get("/notes", "Bearer ${authResponse.token}").content.toModel<NotesResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.notes shouldHaveSize 1
response.notes[0].let {
it.id shouldBe newNoteResponse.noteId
it.title shouldBe "Hey update"
it.note shouldBe "This is updated body"
it.created shouldNotBe null
it.isPinned shouldBe true
}
}
val unpinRequest = PinRequest(isPinned = false).toJson()
patch(
"/note/${newNoteResponse.noteId}/pin",
unpinRequest,
"Bearer ${authResponse.token}"
).content.toModel<NoteResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.noteId shouldNotBe null
}
get("/notes", "Bearer ${authResponse.token}").content.toModel<NotesResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.notes shouldHaveSize 1
response.notes[0].let {
it.id shouldBe newNoteResponse.noteId
it.title shouldBe "Hey update"
it.note shouldBe "This is updated body"
it.created shouldNotBe null
it.isPinned shouldBe false
}
}
// Delete note
delete(
"/note/${newNoteResponse.noteId}",
"Bearer ${authResponse.token}"
).content.toModel<NoteResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.noteId shouldNotBe null
}
// Get empty notes
get("/notes", "Bearer ${authResponse.token}").content.toModel<NotesResponse>().let { response ->
response.status shouldBe State.SUCCESS
response.notes shouldHaveSize 0
}
}
@Test
fun whenNoteIsCreatedByUserA_shouldNotBeAccessibleByUserB() = testApp {
// Create User A
val userTokenA = post(
"/auth/register",
AuthRequest("userA", "userA1234").toJson()
).content.toModel<AuthResponse>().token
// Create User B
val userTokenB = post(
"/auth/register",
AuthRequest("userB", "userB1234").toJson()
).content.toModel<AuthResponse>().token
// User A creates note
val noteRequest = NoteRequest("Hey test", "This is note text").toJson()
val noteId = post(
"/note/new",
noteRequest,
"Bearer $userTokenA"
).content.toModel<NoteResponse>().noteId
// User B tries to delete note created by user A
// Should show access denied (Unauthorized access) message.
delete(
"/note/$noteId",
"Bearer $userTokenB"
).content.toModel<NoteResponse>().let { response ->
response.status shouldBe State.UNAUTHORIZED
response.message shouldBe "Access denied"
response.noteId shouldBe null
}
}
@Test
fun whenNoteRequestIsInvalid_shouldRespondFailedStateWithMessage() = testApp {
val token = post(
"/auth/register",
AuthRequest("usertest", "userA1234").toJson()
).content.toModel<AuthResponse>().token
println(token)
// Create note with invalid title (Whitespaces)
val noteRequest1 = NoteRequest(" Hi ", "This is note text").toJson()
post(
"/note/new",
noteRequest1,
"Bearer $token"
).content.toModel<NoteResponse>().let { response ->
response.status shouldBe State.FAILED
response.noteId shouldBe null
response.message shouldBe "Title should be of min 4 and max 30 character in length"
}
// Create note with invalid note text (Whitespaces)
val noteRequest2 = NoteRequest("Hi there!", " ").toJson()
post(
"/note/new",
noteRequest2,
"Bearer $token"
).content.toModel<NoteResponse>().let { response ->
response.status shouldBe State.FAILED
response.noteId shouldBe null
response.message shouldBe "Title and Note should not be blank"
}
}
@Test
fun whenNoteNotExists_requestedUpdateDelete_shouldShowErrorMessage() = testApp {
val token = post(
"/auth/register",
AuthRequest("usernotenotexist", "test1234").toJson()
).content.toModel<AuthResponse>().token
val noteId = UUID.randomUUID().toString()
put(
"note/$noteId",
NoteRequest("Title", "Body").toJson(),
"Bearer $token"
).content.toModel<NoteResponse>().let {
it.status shouldBe State.NOT_FOUND
it.message shouldBe "Note not exist with ID '$noteId'"
it.noteId shouldBe null
}
delete(
"note/$noteId",
"Bearer $token"
).content.toModel<NoteResponse>().let {
it.status shouldBe State.NOT_FOUND
it.message shouldBe "Note not exist with ID '$noteId'"
it.noteId shouldBe null
}
}
fun testApp(test: TestApplicationEngine.() -> Unit) {
withTestApplication(
{
(environment.config as MapApplicationConfig).apply {
// Set here the properties
put("key.secret", UUID.randomUUID().toString())
put("database.host", sqlContainer.host)
put("database.port", sqlContainer.firstMappedPort.toString())
put("database.name", sqlContainer.databaseName)
put("database.user", sqlContainer.username)
put("database.maxPoolSize", "3")
put("database.driver", sqlContainer.driverClassName)
put("database.password", sqlContainer.password)
}
module()
},
test
)
}
@AfterClass
fun cleanup() {
sqlContainer.stop()
}
inner class DatabaseContainer : PostgreSQLContainer<DatabaseContainer>()
}
data class FailedResponse(
val status: String,
val message: String
)