diff --git a/.idea/runConfigurations/Backend____Jetty.xml b/.idea/runConfigurations/Backend____Jetty.xml deleted file mode 100644 index 17fe544..0000000 --- a/.idea/runConfigurations/Backend____Jetty.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle index 6097f03..d854fb4 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,3 +1,7 @@ +plugins { + id "jacoco" +} + group = 'org.jetbrains.demo.thinkter' version = '0.0.1-SNAPSHOT' @@ -5,6 +9,7 @@ apply plugin: 'kotlin' apply plugin: 'application' dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" @@ -18,6 +23,8 @@ dependencies { testCompile "org.jetbrains.ktor:ktor-test-host:$ktor_version" testCompile "org.jsoup:jsoup:1.9.1" + testCompile "io.mockk:mockk:1.9.1.kotlin12" + compile "org.jetbrains.ktor:ktor-jetty:$ktor_version" compile group: 'com.google.code.gson', name: 'gson', version: '2.8.0' } @@ -42,3 +49,12 @@ kotlin { } mainClassName = 'org.jetbrains.ktor.jetty.DevelopmentHost' + +jacocoTestReport { + reports { + xml.enabled true + html.enabled true + } +} + +check.dependsOn jacocoTestReport diff --git a/backend/src/org/jetbrains/demo/thinkter/Register.kt b/backend/src/org/jetbrains/demo/thinkter/Register.kt index 0752763..db70d0e 100644 --- a/backend/src/org/jetbrains/demo/thinkter/Register.kt +++ b/backend/src/org/jetbrains/demo/thinkter/Register.kt @@ -11,8 +11,6 @@ import org.jetbrains.ktor.util.* fun Route.register(dao: ThinkterStorage, hashFunction: (String) -> String) { post { form -> - val vm = call.request.content.get() - val user = call.sessionOrNull()?.let { dao.user(it.userId) } if (user != null) { call.redirect(LoginResponse(user)) @@ -40,6 +38,7 @@ fun Route.register(dao: ThinkterStorage, hashFunction: (String) -> String) { application.environment.log.error("Failed to register user", e) call.respond(LoginResponse(error = "Failed to register")) } + return@post } call.session(Session(newUser.userId)) diff --git a/backend/test/org/jetbrains/demo/thinkter/ApplicationPageTest.kt b/backend/test/org/jetbrains/demo/thinkter/ApplicationPageTest.kt new file mode 100644 index 0000000..bfb7c40 --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/ApplicationPageTest.kt @@ -0,0 +1,35 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import kotlinx.coroutines.experimental.runBlocking +import org.jetbrains.ktor.application.ApplicationCall +import org.jetbrains.ktor.cio.ByteBufferWriteChannel +import org.jetbrains.ktor.html.HtmlContent +import org.jetbrains.ktor.html.respondHtmlTemplate +import org.junit.Test +import java.nio.charset.Charset + +class ApplicationPageTest { + val appCall = mockk() + + @Test + fun testRenderHTML() { + coEvery { appCall.respond(any()) } just Runs + + runBlocking { + appCall.respondHtmlTemplate(ApplicationPage()) { + caption { +"caption" } + } + } + + coVerify { + appCall.respond(coAssert { + val channel = ByteBufferWriteChannel() + it.writeTo(channel) + val html = channel.toString(Charset.defaultCharset()) + html.contains("caption") && html.contains("yui.yahooapis.com") + }) + } + + } +} \ No newline at end of file diff --git a/backend/test/org/jetbrains/demo/thinkter/Common.kt b/backend/test/org/jetbrains/demo/thinkter/Common.kt new file mode 100644 index 0000000..2b7130c --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/Common.kt @@ -0,0 +1,96 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.Thought +import org.jetbrains.demo.thinkter.model.User +import org.jetbrains.ktor.application.ApplicationCall +import org.jetbrains.ktor.http.HttpHeaders +import org.jetbrains.ktor.http.HttpStatusCode +import org.jetbrains.ktor.request.host +import org.jetbrains.ktor.sessions.SessionConfig +import org.jetbrains.ktor.util.AttributeKey +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +fun MockKMatcherScope.sessionMatcher(): AttributeKey = + match { it.name == "Session" } + +fun MockKMatcherScope.sessionConfigMatcher(): AttributeKey> = + match { it.name == "SessionConfig" } + + +fun ApplicationCall.mockSessionReturningUser(dao: ThinkterStorage) { + every { attributes.contains(sessionMatcher()) } returns true + + every { + attributes + .get(sessionMatcher()) + } returns Session("userId") + + every { dao.user("userId") } returns User("userId", + "email", + "User", + "pwd") +} + + +fun ApplicationCall.mockSessionReturningNothing() { + every { attributes.contains(sessionMatcher()) } returns false +} + + +fun ApplicationCall.checkForbiddenIfSesionReturningNothing(handle: () -> Unit) { + mockSessionReturningNothing() + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(HttpStatusCode.Forbidden) } +} + +fun ApplicationCall.mockHostReferrerHash(hash: (String) -> String) { + every { request.host() } returns "host" + + every { request.headers[HttpHeaders.Referrer] } returns "http://abc/referrer" + + every { hash(any()) } answers { firstArg().reversed() } +} + + +fun mockGetThought(dao: ThinkterStorage, ts: Long) { + every { + dao.getThought(any()) + } answers { Thought(firstArg(), + "userId", + "text", + formatDate(ts + firstArg()), + null) } +} + +private fun formatDate(date: Long): String { + return Instant.ofEpochMilli(date) + .atZone(ZoneId.systemDefault()) + .toOffsetDateTime() + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) +} + + +fun mockUser(dao: ThinkterStorage, pwdHash: String? = null): User { + val user = User("abcdef", "abc@def", "Abc Def", pwdHash ?: "") + every { dao.user("abcdef", pwdHash) } returns user + return user +} + +fun ApplicationCall.mockPutSession() { + every { + attributes + .get(sessionConfigMatcher()) + .sessionType + } returns Session::class + + every { attributes.put(sessionMatcher(), any()) } just Runs +} + diff --git a/backend/test/org/jetbrains/demo/thinkter/DeleteKtTest.kt b/backend/test/org/jetbrains/demo/thinkter/DeleteKtTest.kt new file mode 100644 index 0000000..d0197b5 --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/DeleteKtTest.kt @@ -0,0 +1,104 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.PostThoughtToken +import org.jetbrains.demo.thinkter.model.RpcData +import org.jetbrains.demo.thinkter.model.Thought +import org.jetbrains.ktor.http.HttpMethod +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.routing.HttpMethodRouteSelector +import org.jetbrains.ktor.routing.Routing +import org.junit.Before +import org.junit.Test + +class DeleteKtTest { + val route = mockk() + val dao = mockk() + val hash = mockk<(String) -> String>() + val locations = mockk() + + val getThoughtDelete = RouteBlockSlot() + val postThoughtDelete = RouteBlockSlot() + + @Before + fun setUp() { + route.mockDsl(locations) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getThoughtDelete) + } + mockSelect(HttpMethodRouteSelector(HttpMethod.Post)) { + captureBlock(postThoughtDelete) + } + } + } + + route.delete(dao, hash) + } + + @Test + fun testGetThoughtDeleteOk() { + val data = ThoughtDelete(1, System.currentTimeMillis() - 6000, "abc") + getThoughtDelete.invokeBlock(locations, data) { handle -> + mockSessionReturningUser(dao) + mockHostReferrerHash(hash) + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { + it.user == "userId" && + it.code.contains("cba:tsoh:dIresu") + }) + } + } + } + + @Test + fun testGetThoughtDeleteNotLoggedIn() { + val data = ThoughtDelete(0, 0, "abc") + getThoughtDelete.invokeBlock(locations, data) { handle -> + checkForbiddenIfSesionReturningNothing(handle) + } + } + + @Test + fun testPostThoughtDeleteOk() { + val ts = System.currentTimeMillis() - 6000 + val data = ThoughtDelete(1, ts, "cba:tsoh:dIresu:" + ts.toString().reversed()) + postThoughtDelete.invokeBlock(locations, data) { handle -> + mockSessionReturningUser(dao) + mockHostReferrerHash(hash) + mockGetThought(dao, ts) + + every { + dao.deleteThought(1) + } just Runs + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(ofType(RpcData::class)) + } + } + } + + @Test + fun testPostThoughtDeleteNotLoggedIn() { + val data = ThoughtDelete(1, 0, "abc") + val ts = System.currentTimeMillis() + postThoughtDelete.invokeBlock(locations, data) { handle -> + every { + dao.getThought(1) + } answers { Thought(1, "userId", "text", ts.toString(), null) } + + checkForbiddenIfSesionReturningNothing(handle) + } + } + +} \ No newline at end of file diff --git a/backend/test/org/jetbrains/demo/thinkter/IndexKtTest.kt b/backend/test/org/jetbrains/demo/thinkter/IndexKtTest.kt new file mode 100644 index 0000000..06b190d --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/IndexKtTest.kt @@ -0,0 +1,146 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.IndexResponse +import org.jetbrains.demo.thinkter.model.PollResponse +import org.jetbrains.ktor.cio.ByteBufferWriteChannel +import org.jetbrains.ktor.html.HtmlContent +import org.jetbrains.ktor.http.HttpHeaders +import org.jetbrains.ktor.http.HttpMethod +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.routing.HttpHeaderRouteSelector +import org.jetbrains.ktor.routing.HttpMethodRouteSelector +import org.jetbrains.ktor.routing.Routing +import org.junit.Before +import org.junit.Test +import java.nio.charset.Charset + +class IndexKtTest { + val route = mockk() + val dao = mockk() + val locations = mockk() + + val getHtmlIndex = RouteBlockSlot() + val getJsonIndex = RouteBlockSlot() + val getJsonPoll = RouteBlockSlot() + + @Before + fun setUp() { + route.mockDsl(locations) { + mockSelect(HttpHeaderRouteSelector(HttpHeaders.Accept, "text/html")) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getHtmlIndex) + } + } + } + mockSelect(HttpHeaderRouteSelector(HttpHeaders.Accept, "application/json")) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getJsonIndex) + } + } + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getJsonPoll) + } + } + } + } + + route.index(dao) + + } + + @Test + fun testGetIndexHtml() { + getHtmlIndex.invokeBlock(locations, Index()) { handle -> + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(coAssert { + val channel = ByteBufferWriteChannel() + it.writeTo(channel) + val html = channel.toString(Charset.defaultCharset()) + html.contains("Thinkter") + }) + } + } + } + + @Test + fun testGetIndexJson() { + getJsonIndex.invokeBlock(locations, Index()) { handle -> + mockSessionReturningUser(dao) + mockGetThought(dao, 0) + + every { dao.top(10) } returns (1..10).toList() + + every { dao.latest(10) } returns (1..10).toList() + + coEvery { respond(any()) } just Runs + + every { response.pipeline.intercept(any(), any()) } just Runs + + handle() + + coVerify { + respond(assert(msg = "response should have top and latest with ids from one to ten") { + val oneToTen = (1..10).toList() + + it.top.map { it.id }.containsAll(oneToTen) + && it.latest.map { it.id }.containsAll(oneToTen) + }) + } + } + } + + @Test + fun testGetPollJsonBlank() { + getJsonPoll.invokeBlock(locations, Poll("")) { handle -> + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { it.count == "0" }) + } + } + } + + @Test + fun testGetPollJsonOne() { + checkPoll("9", "1") + } + + @Test + fun testGetPollJsonFive() { + checkPoll("5", "5") + } + + @Test + fun testGetPollJsonTenPlus() { + checkPoll("0", "10+") + } + + private fun checkPoll(pollTime: String, responseCount: String) { + getJsonPoll.invokeBlock(locations, Poll(pollTime)) { handle -> + mockGetThought(dao, 0) + + every { dao.latest(10) } returns (1..10).toList() + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { it.count == responseCount }) + } + } + } + +} + diff --git a/backend/test/org/jetbrains/demo/thinkter/LoginKtTest.kt b/backend/test/org/jetbrains/demo/thinkter/LoginKtTest.kt new file mode 100644 index 0000000..346d0e3 --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/LoginKtTest.kt @@ -0,0 +1,186 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.LoginResponse +import org.jetbrains.demo.thinkter.model.User +import org.jetbrains.ktor.http.HttpMethod +import org.jetbrains.ktor.http.HttpStatusCode +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.routing.HttpMethodRouteSelector +import org.jetbrains.ktor.routing.Routing +import org.junit.Before +import org.junit.Test + +class LoginKtTest { + val route = mockk() + val dao = mockk() + val hash = mockk<(String) -> String>() + val locations = mockk() + + val getLogin = RouteBlockSlot() + val postLogin = RouteBlockSlot() + val postLogout = RouteBlockSlot() + + @Before + fun setUp() { + route.mockDsl(locations) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getLogin) + } + mockSelect(HttpMethodRouteSelector(HttpMethod.Post)) { + captureBlock(postLogin) + } + } + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Post)) { + captureBlock(postLogout) + } + } + } + + route.login(dao, hash) + + } + + @Test + fun testGetLoginOk() { + val user = User("userId", + "email", + "display", + "pwd") + every { + dao.user("userId", any()) + } returns user + + getLogin.invokeBlock(locations, + Login("abc", + "def", + "ghi")) { handle -> + every { attributes.contains(sessionMatcher()) } returns true + + every { + attributes + .get(sessionMatcher()) + } returns Session("userId") + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(LoginResponse(user)) } + } + } + + @Test + fun testGetLoginNotLoggedIn() { + getLogin.invokeBlock(locations, + Login("abc", + "def", + "ghi")) { handle -> + every { attributes.contains(sessionMatcher()) } returns false + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(HttpStatusCode.Forbidden) } + } + } + + @Test + fun testPostLoginOk() { + postLogin.invokeBlock(locations, + Login("abcdef", + "ghiklm")) { handle -> + + every { hash.invoke("ghiklm") } returns "mlkihg" + + val user = mockUser(dao, "mlkihg") + + mockPutSession() + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(LoginResponse(user)) } + + coVerify { attributes.put(sessionMatcher(), Session("abcdef")) } + } + } + + @Test + fun testPostLoginShortUsername() { + postLogin.invokeBlock(locations, + Login("abc", + "defghi")) { handle -> + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(LoginResponse(error = "Invalid username or password")) } + } + } + + @Test + fun testPostLoginShortPassword() { + postLogin.invokeBlock(locations, + Login("abcdef", + "ghi")) { handle -> + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(LoginResponse(error = "Invalid username or password")) } + } + } + + @Test + fun testPostLoginWrongUsername() { + postLogin.invokeBlock(locations, + Login("#!$%#$$@#", + "defghi")) { handle -> + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(LoginResponse(error = "Invalid username or password")) } + } + } + + @Test + fun testPostLogoutOk() { + postLogout.invokeBlock(locations, + Logout()) { handle -> + + every { hash.invoke("ghiklm") } returns "mlkihg" + + every { + dao.user("abcdef", "mlkihg") + } returns User("abcdef", + "abc@def", + "Abc Def", + "mlkihg") + + every { + attributes + .getOrNull(sessionConfigMatcher()) + } returns null + + every { attributes.remove(sessionMatcher()) } just Runs + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(HttpStatusCode.OK) } + + verify { attributes.remove(sessionMatcher()) } + } + } +} diff --git a/backend/test/org/jetbrains/demo/thinkter/PostThoughtKtTest.kt b/backend/test/org/jetbrains/demo/thinkter/PostThoughtKtTest.kt new file mode 100644 index 0000000..ff13962 --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/PostThoughtKtTest.kt @@ -0,0 +1,98 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.PostThoughtResult +import org.jetbrains.demo.thinkter.model.PostThoughtToken +import org.jetbrains.ktor.http.HttpMethod +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.routing.HttpMethodRouteSelector +import org.jetbrains.ktor.routing.Routing +import org.junit.Before +import org.junit.Test + +class PostThoughtKtTest { + val route = mockk() + val dao = mockk() + val hash = mockk<(String) -> String>() + val locations = mockk() + + val getPostThought = RouteBlockSlot() + val postPostThought = RouteBlockSlot() + + @Before + fun setUp() { + route.mockDsl(locations) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getPostThought) + } + mockSelect(HttpMethodRouteSelector(HttpMethod.Post)) { + captureBlock(postPostThought) + } + } + } + + route.postThought(dao, hash) + } + + @Test + fun testGetPostThoughtOk() { + getPostThought.invokeBlock(locations, PostThought()) { handle -> + mockSessionReturningUser(dao) + mockHostReferrerHash(hash) + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { + it.user == "userId" && + it.code.contains("cba:tsoh:dIresu") + }) + } + } + } + + @Test + fun testGetPostThoughtNotLoggedIn() { + getPostThought.invokeBlock(locations, PostThought()) { handle -> + checkForbiddenIfSesionReturningNothing(handle) + } + } + + @Test + fun testPostPostThoughtOk() { + val ts = System.currentTimeMillis() - 6000 + val data = PostThought("text", ts, "cba:tsoh:dIresu:" + ts.toString().reversed(), null) + postPostThought.invokeBlock(locations, data) { handle -> + mockSessionReturningUser(dao) + mockHostReferrerHash(hash) + mockGetThought(dao, ts) + + every { + dao.createThought("userId", "text", any(), any()) + } returns 1 + + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { + it.thought.id == 1 && + it.thought.text == "text" + }) + } + } + } + + @Test + fun testPostPostThoughtNotLoggedIn() { + postPostThought.invokeBlock(locations, PostThought()) { handle -> + checkForbiddenIfSesionReturningNothing(handle) + } + } +} diff --git a/backend/test/org/jetbrains/demo/thinkter/RegisterKtTest.kt b/backend/test/org/jetbrains/demo/thinkter/RegisterKtTest.kt new file mode 100644 index 0000000..28fabf2 --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/RegisterKtTest.kt @@ -0,0 +1,233 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.LoginResponse +import org.jetbrains.demo.thinkter.model.User +import org.jetbrains.ktor.application.ApplicationFeature +import org.jetbrains.ktor.http.HttpHeaders +import org.jetbrains.ktor.http.HttpMethod +import org.jetbrains.ktor.http.HttpStatusCode +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.routing.HttpMethodRouteSelector +import org.jetbrains.ktor.routing.Routing +import org.junit.Before +import org.junit.Test + +class RegisterKtTest { + val route = mockk() + val dao = mockk() + val hash = mockk<(String) -> String>() + val locations = mockk() + + val getRegister = RouteBlockSlot() + val postRegister = RouteBlockSlot() + + @Before + fun setUp() { + route.mockDsl(locations) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Post)) { + captureBlock(postRegister) + } + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getRegister) + } + } + } + + route.register(dao, hash) + } + + @Test + fun testPostRegisterOk() { + val data = Register(userId = "abcdef", password = "abcdefghi") + postRegister.invokeBlock(locations, data) { handle -> + mockSessionReturningNothing() + mockHostReferrerHash(hash) + mockPutSession() + + every { dao.user("abcdef") } returns null + + every { dao.createUser(any()) } just Runs + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { + it.user?.userId == "abcdef" + }) + } + + } + } + + @Test + fun testPostRegisterLoggedIn() { + val data = Register() + postRegister.invokeBlock(locations, data) { handle -> + mockSessionReturningUser(dao) + + every { request.headers[HttpHeaders.Host] } returns "host" + + every { + application + .attributes + .get(ApplicationFeature.registry) + .get(Locations.key) + .href(any()) + } returns "/redirect" + + every { + response.headers.append(any(), any()) + } just Runs + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + response.headers.append(HttpHeaders.Location, "http://host/redirect") + respond(HttpStatusCode.Found) + } + } + } + + + @Test + fun testPostRegisterPasswordSize() { + checkRegisterError( + Register(userId = "abcdefghi", password = "abcd"), + "Password should be at least 6 characters long") + } + + @Test + fun testPostRegisterLoginSize() { + checkRegisterError( + Register(userId = "abc", password = "abcdefghi"), + "Login should be at least 4 characters long") + } + + @Test + fun testPostRegisterLoginConsistsOfDigitsLetters() { + checkRegisterError( + Register(userId = "#@!$!#$", password = "abcdefghi"), + "Login should be consists of digits, letters, dots or underscores") + } + + private fun checkRegisterError(data: Register, msg: String) { + postRegister.invokeBlock(locations, data) { handle -> + mockSessionReturningNothing() + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(LoginResponse(error = msg)) + } + } + } + + @Test + fun testPostRegisterSameUserRegistered() { + val data = Register(userId = "abcdef", password = "abcdefghi") + postRegister.invokeBlock(locations, data) { handle -> + mockSessionReturningNothing() + mockHostReferrerHash(hash) + + val user = User("abcdef", "abc@def", "Abc Def", "") + every { dao.user("abcdef") } returnsMany listOf(null, user) + + every { dao.createUser(any()) } throws RuntimeException("failed to create user") + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(LoginResponse(error = "User with the following login is already registered")) + } + + } + } + + @Test + fun testPostRegisterSameEmailUserRegistered() { + val data = Register(userId = "abcdef", password = "abcdefghi", email = "user@email") + postRegister.invokeBlock(locations, data) { handle -> + mockSessionReturningNothing() + mockHostReferrerHash(hash) + + every { dao.user(any()) } returns null + + every { dao.createUser(any()) } throws RuntimeException("failed to create user") + + every { dao.userByEmail("user@email") } returns mockk() + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(LoginResponse(error = "User with the following email user@email is already registered")) + } + } + } + + @Test + fun testPostRegisterFailed() { + val data = Register(userId = "abcdef", password = "abcdefghi", email = "user@email") + postRegister.invokeBlock(locations, data) { handle -> + mockSessionReturningNothing() + mockHostReferrerHash(hash) + + every { dao.user(any()) } returns null + + every { dao.createUser(any()) } throws RuntimeException("failed to create user") + + every { dao.userByEmail("user@email") } returns null + + every { route.application.environment.log.error(any()) } just Runs + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(LoginResponse(error = "Failed to register")) + } + } + } + + @Test + fun testPostRegisterUserExists() { + val data = Register(userId = "abcdef", password = "abcdefghi") + postRegister.invokeBlock(locations, data) { handle -> + mockSessionReturningNothing() + mockUser(dao) + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(LoginResponse(error = "User with the following login is already registered")) + } + + } + } + + @Test + fun testGetRegisterNotAllowed() { + getRegister.invokeBlock(locations, Register()) { handle -> + coEvery { respond(any()) } just Runs + + handle() + + coVerify { respond(HttpStatusCode.MethodNotAllowed) } + } + } +} \ No newline at end of file diff --git a/backend/test/org/jetbrains/demo/thinkter/RouteMockDsl.kt b/backend/test/org/jetbrains/demo/thinkter/RouteMockDsl.kt new file mode 100644 index 0000000..f2f597b --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/RouteMockDsl.kt @@ -0,0 +1,82 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import kotlinx.coroutines.experimental.runBlocking +import org.jetbrains.ktor.application.ApplicationCall +import org.jetbrains.ktor.application.ApplicationFeature +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.pipeline.PipelineContext +import org.jetbrains.ktor.pipeline.PipelineInterceptor +import org.jetbrains.ktor.routing.Route +import org.jetbrains.ktor.routing.RouteSelector +import org.jetbrains.ktor.routing.Routing +import org.jetbrains.ktor.routing.application +import kotlin.reflect.KClass + +fun Route.mockDsl(locations: Locations, block: RouteDslMock.() -> Unit) = RouteDslMock(this, locations).block() + +typealias RouteBlockSlot = CapturingSlot> + +class RouteDslMock(val route: Route, val locations: Locations) { + init { + every { + route + .application + .attributes + .get(ApplicationFeature.registry) + .get(Locations.key) + } returns locations + } + + inline fun RouteDslMock.mockObj(noinline block: RouteDslMock.() -> Unit) { + mockObj(this.route, T::class, block) + } + + @PublishedApi + internal fun mockObj(route: Route, dataClass: KClass<*>, block: RouteDslMock.() -> Unit) { + val nextRoute = mockk() + every { locations.createEntry(route, dataClass) } returns nextRoute + every { nextRoute.parent } returns route + + RouteDslMock(nextRoute, locations).block() + } + + fun RouteDslMock.mockSelect(selector: RouteSelector, block: RouteDslMock.() -> Unit) { + val nextRoute = mockk() + every { route.select(selector) } returns nextRoute + every { nextRoute.parent } returns route + + RouteDslMock(nextRoute, locations).block() + } + + fun RouteDslMock.captureBlock(slot: RouteBlockSlot) { + every { route.handle(capture(slot)) } just Runs + } +} + +typealias CallInvoker = () -> Unit + +fun RouteBlockSlot.invokeBlock(locations: Locations, + data: Any, + block: ApplicationCall.(CallInvoker) -> Unit) { + + runBlocking { + val ctx = mockk>() + + val call = mockk() + + every { + val dataCls = data.javaClass.kotlin + locations.resolve(dataCls, call) + } returns data + + every { ctx.subject } returns call + + call.block { + runBlocking { + captured.invoke(ctx, call) + } + } + } + +} diff --git a/backend/test/org/jetbrains/demo/thinkter/UserPageKtTest.kt b/backend/test/org/jetbrains/demo/thinkter/UserPageKtTest.kt new file mode 100644 index 0000000..c36b7f6 --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/UserPageKtTest.kt @@ -0,0 +1,71 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.UserThoughtsResponse +import org.jetbrains.ktor.http.HttpMethod +import org.jetbrains.ktor.http.HttpStatusCode +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.routing.HttpMethodRouteSelector +import org.jetbrains.ktor.routing.Routing +import org.junit.Before +import org.junit.Test + +class UserPageKtTest { + val route = mockk() + val dao = mockk() + val hash = mockk<(String) -> String>() + val locations = mockk() + + val getUserThoughts = RouteBlockSlot() + + @Before + fun setUp() { + route.mockDsl(locations) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getUserThoughts) + } + } + } + + route.userPage(dao) + } + + @Test + fun testGetUserThoughtsOk() { + getUserThoughts.invokeBlock(locations, UserThoughts("abcdef")) { handle -> + mockHostReferrerHash(hash) + mockUser(dao) + mockGetThought(dao, 0) + + every { dao.userThoughts("abcdef") } returns listOf(1, 2, 3) + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { + it.thoughts.any { it.id == 2 && it.text == "text" } + }) + } + } + } + + @Test + fun testGetUserThoughtsNoUserFound() { + getUserThoughts.invokeBlock(locations, UserThoughts("abcdef")) { handle -> + every { dao.user("abcdef") } returns null + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(HttpStatusCode.NotFound.description("User abcdef doesn't exist")) + } + } + } + +} \ No newline at end of file diff --git a/backend/test/org/jetbrains/demo/thinkter/ViewThoughtKtTest.kt b/backend/test/org/jetbrains/demo/thinkter/ViewThoughtKtTest.kt new file mode 100644 index 0000000..cefe093 --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/ViewThoughtKtTest.kt @@ -0,0 +1,77 @@ +package org.jetbrains.demo.thinkter + +import io.mockk.* +import org.jetbrains.demo.thinkter.dao.ThinkterStorage +import org.jetbrains.demo.thinkter.model.ViewThoughtResponse +import org.jetbrains.ktor.http.HttpMethod +import org.jetbrains.ktor.locations.Locations +import org.jetbrains.ktor.routing.HttpMethodRouteSelector +import org.jetbrains.ktor.routing.Routing +import org.junit.Before +import org.junit.Test + +class ViewThoughtKtTest { + val route = mockk() + val dao = mockk() + val hash = mockk<(String) -> String>() + val locations = mockk() + + val getViewThought = RouteBlockSlot() + val postViewThought = RouteBlockSlot() + + @Before + fun setUp() { + route.mockDsl(locations) { + mockObj { + mockSelect(HttpMethodRouteSelector(HttpMethod.Get)) { + captureBlock(getViewThought) + } + mockSelect(HttpMethodRouteSelector(HttpMethod.Post)) { + captureBlock(postViewThought) + } + } + } + + route.viewThought(dao, hash) + } + + @Test + fun testGetPostThoughtOk() { + getViewThought.invokeBlock(locations, ViewThought(1)) { handle -> + mockSessionReturningUser(dao) + mockHostReferrerHash(hash) + mockGetThought(dao, 0) + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { + it.thought.userId == "userId" && + it.code!!.contains("cba:tsoh:dIresu") + }) + } + } + } + + @Test + fun testGetPostThoughtNotLoggedIn() { + getViewThought.invokeBlock(locations, ViewThought(1)) { handle -> + mockGetThought(dao, 0) + mockSessionReturningNothing() + + coEvery { respond(any()) } just Runs + + handle() + + coVerify { + respond(assert { + it.thought.id == 1 && + it.code == null + }) + } + } + } + +} \ No newline at end of file diff --git a/backend/test/org/jetbrains/demo/thinkter/dao/ThinkterDatabaseTest.kt b/backend/test/org/jetbrains/demo/thinkter/dao/ThinkterDatabaseTest.kt new file mode 100644 index 0000000..7f7f89e --- /dev/null +++ b/backend/test/org/jetbrains/demo/thinkter/dao/ThinkterDatabaseTest.kt @@ -0,0 +1,301 @@ +package org.jetbrains.demo.thinkter.dao + +import io.mockk.* +import org.jetbrains.demo.thinkter.model.User +import org.jetbrains.squash.connection.DatabaseConnection +import org.jetbrains.squash.dialect.BaseSQLDialect +import org.jetbrains.squash.expressions.alias +import org.jetbrains.squash.expressions.invoke +import org.jetbrains.squash.results.Response +import org.jetbrains.squash.results.ResultRow +import org.jetbrains.squash.results.get +import org.jetbrains.squash.statements.DeleteQueryStatement +import org.jetbrains.squash.statements.InsertValuesStatement +import org.jetbrains.squash.statements.QueryStatement +import org.junit.Assert +import org.junit.Test +import java.time.LocalDateTime + +class ThinkterDatabaseTest { + val connection = mockk("db") + fun tx() = connection.createTransaction() + + init { + every { tx().databaseSchema().create(any()) } just Runs + every { tx().close() } just Runs + } + + val database = ThinkterDatabase(connection) + + @Test + fun countReplies() { + val response = mockk() + val row = mockk() + every { + with(tx()) { + any().execute() + } + } returns response + + mockSingleRowResponse(response, row) + + every { + row.get(0) + } returns 3 + + val nReplies = database.countReplies(1) + Assert.assertEquals(3, nReplies) + + verify { + with(tx()) { + assert { + "SELECT COUNT(Thoughts.id), Thoughts.reply_to = ? FROM Thoughts" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun createThought() { + every { + with(tx()) { + any>().execute() + } + } returns 1 + + database.createThought("userId", "text") + + verify { + with(tx()) { + assert> { + "INSERT INTO Thoughts (user_id, \"date\", reply_to, text) VALUES (?, ?, NULL, ?)" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun deleteThought() { + every { + with(tx()) { + any>().execute() + } + } just Runs + + database.deleteThought(1) + + verify { + with(tx()) { + assert> { + "DELETE FROM Thoughts WHERE Thoughts.id = ?" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun getThought() { + val response = mockk() + val row = mockk() + every { + with(tx()) { + any().execute() + } + } returns response + + mockSingleRowResponse(response, row) + + + every { row[Thoughts.user] } returns "user" + every { row[Thoughts.text] } returns "text" + + every { + row[Thoughts.date] + } returns LocalDateTime.now() + + every { row[Thoughts.replyTo] } returns null + + database.getThought(1) + + verify { + with(tx()) { + assert { + "SELECT * FROM Thoughts WHERE Thoughts.id = ?" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun userThoughts() { + val response = mockk() + val row = mockk() + every { + with(tx()) { + any().execute() + } + } returns response + + mockSingleRowResponse(response, row) + + every { row[Thoughts.id] } returns 1 + + database.userThoughts("id") + + verify { + with(tx()) { + assert { + "SELECT Thoughts.id FROM Thoughts WHERE Thoughts.user_id = ? ORDER BY Thoughts.\"date\" DESC NULLS LAST LIMIT ?" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun user() { + val response = mockk() + val row = mockk() + every { + with(tx()) { + any().execute() + } + } returns response + + mockSingleRowResponse(response, row) + + + every { row[Users.email] } returns "email" + every { row[Users.displayName] } returns "user" + every { row[Users.passwordHash] } returns "hash" + + database.user("user", "hash") + + verify { + with(tx()) { + assert { + "SELECT * FROM Users WHERE Users.id = ?" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun userByEmail() { + val response = mockk() + val row = mockk() + every { + with(tx()) { + any().execute() + } + } returns response + + mockSingleRowResponse(response, row) + + + every { row[Users.id] } returns "user" + every { row[Users.email] } returns "email" + every { row[Users.displayName] } returns "user" + every { row[Users.passwordHash] } returns "hash" + + database.userByEmail("email") + + verify { + with(tx()) { + assert { + "SELECT * FROM Users WHERE Users.email = ?" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun createUser() { + every { + with(tx()) { + any>().execute() + } + } just Runs + + database.createUser(User("id", "email", "name", "pwd")) + + verify { + with(tx()) { + assert> { + "INSERT INTO Users (id, display_name, email, password_hash) VALUES (?, ?, ?, ?)" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun top() { + val response = mockk() + val row = mockk() + every { + with(tx()) { + any().execute() + } + } returns response + + mockSingleRowResponse(response, row) + + val k2 = Thoughts.alias("k2") + every { row[Thoughts.id(k2)] } returns 1 + + database.top() + + verify { + with(tx()) { + assert { + "SELECT Thoughts.id, COUNT(k2.id) FROM Thoughts LEFT OUTER JOIN Thoughts AS k2 ON Thoughts.id = k2.reply_to GROUP BY Thoughts.id ORDER BY COUNT(k2.id) DESC NULLS LAST LIMIT ?" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + @Test + fun latest() { + val response = mockk() + val row = mockk() + every { + with(tx()) { + any().execute() + } + } returns response + + mockSingleRowResponse(response, row) + + every { row[Thoughts.id] } returns 1 + + database.latest(1) + + verify { + with(tx()) { + assert { + "SELECT Thoughts.id FROM Thoughts WHERE Thoughts.\"date\" > ? ORDER BY Thoughts.\"date\" DESC NULLS LAST LIMIT ?" == + BaseSQLDialect("dialect").statementSQL(it).sql + }.execute() + } + } + } + + + private fun mockSingleRowResponse(response: Response, row: ResultRow) { + every { + response.iterator().hasNext() + } returnsMany listOf(true, false) + + every { + response.iterator().next() + } returnsMany listOf(row) + } +} diff --git a/backend/testResources/logback-test.xml b/backend/testResources/logback-test.xml new file mode 100644 index 0000000..ab4454b --- /dev/null +++ b/backend/testResources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 74bb778..5281128 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Wed Nov 08 07:41:52 CET 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.2.1-all.zip