Merge pull request #1 from wbrawner/add-tests

Add tests
This commit is contained in:
github-actions[bot] 2023-09-27 12:40:22 +00:00 committed by GitHub
commit ff7b80a273
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 691 additions and 39 deletions

25
.github/workflows/pull-request.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Pull request workflow
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Run checks with Gradle Wrapper
run: ./gradlew check
automerge:
runs-on: ubuntu-latest
if: ${{ github.actor == 'wbrawner' }}
steps:
- name: Enable auto-merge
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View file

@ -11,8 +11,7 @@ dependencies {
api(libs.ktor.server.core) api(libs.ktor.server.core)
api(libs.ktor.serialization) api(libs.ktor.serialization)
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit.jupiter.api) testImplementation(project(":testhelpers"))
testRuntimeOnly(libs.junit.jupiter.engine)
} }
tasks.getByName<Test>("test") { tasks.getByName<Test>("test") {

View file

@ -15,9 +15,9 @@ data class BudgetRequest(
@Serializable @Serializable
data class BudgetResponse( data class BudgetResponse(
val id: String, val id: String,
val name: String?, val name: String? = null,
val description: String?, val description: String? = null,
private val users: List<UserPermissionResponse> val users: List<UserPermissionResponse>
) { ) {
constructor(budget: Budget, users: Iterable<UserPermission>) : this( constructor(budget: Budget, users: Iterable<UserPermission>) : this(
Objects.requireNonNull<String>(budget.id), Objects.requireNonNull<String>(budget.id),

View file

@ -1,5 +1,4 @@
import java.net.URI import java.net.URI
import java.util.*
plugins { plugins {
java java
@ -27,8 +26,8 @@ dependencies {
implementation(libs.logback) implementation(libs.logback)
implementation(libs.mail) implementation(libs.mail)
testImplementation(project(":testhelpers")) testImplementation(project(":testhelpers"))
testImplementation(libs.junit.jupiter.api) testImplementation(libs.ktor.client.content.negotiation)
testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.ktor.server.test)
} }
description = "twigs-server" description = "twigs-server"

View file

@ -12,6 +12,8 @@ import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.* import io.ktor.server.auth.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.plugins.callloging.* import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.cors.routing.*
@ -23,10 +25,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.lang.RuntimeException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args) fun main() {
embeddedServer(CIO, port = System.getenv("PORT")?.toIntOrNull() ?: 8080) {
module()
}.start(wait = true)
}
private const val DATABASE_VERSION = 3 private const val DATABASE_VERSION = 3
@ -63,7 +68,7 @@ fun Application.module() {
username = environment.config.propertyOrNull("twigs.smtp.user")?.getString(), username = environment.config.propertyOrNull("twigs.smtp.user")?.getString(),
password = environment.config.propertyOrNull("twigs.smtp.pass")?.getString(), password = environment.config.propertyOrNull("twigs.smtp.pass")?.getString(),
), ),
metadataRepository = MetadataRepository(it), metadataRepository = JdbcMetadataRepository(it),
budgetRepository = JdbcBudgetRepository(it), budgetRepository = JdbcBudgetRepository(it),
categoryRepository = JdbcCategoryRepository(it), categoryRepository = JdbcCategoryRepository(it),
passwordResetRepository = JdbcPasswordResetRepository(it), passwordResetRepository = JdbcPasswordResetRepository(it),

View file

@ -1,10 +1,7 @@
ktor { ktor {
deployment { deployment {
port = 8080 port = 8080
port = ${?TWIGS_PORT} port = ${?PORT}
}
application {
modules = [ com.wbrawner.twigs.server.ApplicationKt.module ]
} }
} }

View file

@ -3,9 +3,9 @@ package com.wbrawner.twigs.server
import com.wbrawner.twigs.model.* import com.wbrawner.twigs.model.*
import com.wbrawner.twigs.storage.RecurringTransactionRepository import com.wbrawner.twigs.storage.RecurringTransactionRepository
import com.wbrawner.twigs.storage.TransactionRepository import com.wbrawner.twigs.storage.TransactionRepository
import com.wbrawner.twigs.test.helpers.repository.FakeRecurringTransactionsRepository import com.wbrawner.twigs.test.helpers.repository.FakeRecurringTransactionRepository
import com.wbrawner.twigs.test.helpers.repository.FakeTransactionRepository import com.wbrawner.twigs.test.helpers.repository.FakeTransactionRepository
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -21,13 +21,13 @@ class RecurringTransactionProcessingJobTest {
@BeforeEach @BeforeEach
fun setup() { fun setup() {
recurringTransactionRepository = FakeRecurringTransactionsRepository() recurringTransactionRepository = FakeRecurringTransactionRepository()
transactionRepository = FakeTransactionRepository() transactionRepository = FakeTransactionRepository()
job = RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository) job = RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
} }
@Test @Test
fun `daily transactions are created every day`() = runBlockingTest { fun `daily transactions are created every day`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -49,7 +49,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `daily transactions are only created once per day`() = runBlockingTest { fun `daily transactions are only created once per day`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -71,7 +71,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `daily transactions are created every other day`() = runBlockingTest { fun `daily transactions are created every other day`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -90,7 +90,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `weekly transactions are created every thursday`() = runBlockingTest { fun `weekly transactions are created every thursday`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -113,7 +113,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `weekly transactions are created every third thursday`() = runBlockingTest { fun `weekly transactions are created every third thursday`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -134,7 +134,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `monthly transactions are created every 1st of month`() = runBlockingTest { fun `monthly transactions are created every 1st of month`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -157,7 +157,7 @@ class RecurringTransactionProcessingJobTest {
@Test @Test
fun `monthly transactions are created every last day of month when greater than max days in month`() = fun `monthly transactions are created every last day of month when greater than max days in month`() =
runBlockingTest { runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -180,7 +180,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `monthly transactions are created every 6 months`() = runBlockingTest { fun `monthly transactions are created every 6 months`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -201,7 +201,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `monthly transactions are created every 2nd tuesday`() = runBlockingTest { fun `monthly transactions are created every 2nd tuesday`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -228,7 +228,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `monthly transactions are created every last friday`() = runBlockingTest { fun `monthly transactions are created every last friday`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -255,7 +255,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `monthly transactions are created in the new year`() = runBlockingTest { fun `monthly transactions are created in the new year`() = runTest {
val start = Instant.parse("1971-01-01T00:00:00Z") val start = Instant.parse("1971-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -280,7 +280,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `yearly transactions are created every march 31st`() = runBlockingTest { fun `yearly transactions are created every march 31st`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -303,7 +303,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `yearly transactions are created every other march 31st`() = runBlockingTest { fun `yearly transactions are created every other march 31st`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(
@ -324,7 +324,7 @@ class RecurringTransactionProcessingJobTest {
} }
@Test @Test
fun `yearly transactions are created every february 29th`() = runBlockingTest { fun `yearly transactions are created every february 29th`() = runTest {
val start = Instant.parse("1970-01-01T00:00:00Z") val start = Instant.parse("1970-01-01T00:00:00Z")
recurringTransactionRepository.save( recurringTransactionRepository.save(
RecurringTransaction( RecurringTransaction(

View file

@ -0,0 +1,60 @@
package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.server.moduleWithDependencies
import com.wbrawner.twigs.test.helpers.FakeEmailService
import com.wbrawner.twigs.test.helpers.repository.*
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import org.junit.jupiter.api.BeforeEach
open class ApiTest {
lateinit var budgetRepository: FakeBudgetRepository
lateinit var categoryRepository: FakeCategoryRepository
lateinit var emailService: FakeEmailService
lateinit var metadataRepository: FakeMetadataRepository
lateinit var passwordResetRepository: FakePasswordResetRepository
lateinit var permissionRepository: FakePermissionRepository
lateinit var recurringTransactionRepository: FakeRecurringTransactionRepository
lateinit var sessionRepository: FakeSessionRepository
lateinit var transactionRepository: FakeTransactionRepository
lateinit var userRepository: FakeUserRepository
@BeforeEach
fun setup() {
budgetRepository = FakeBudgetRepository()
categoryRepository = FakeCategoryRepository()
emailService = FakeEmailService()
metadataRepository = FakeMetadataRepository()
passwordResetRepository = FakePasswordResetRepository()
permissionRepository = FakePermissionRepository()
recurringTransactionRepository = FakeRecurringTransactionRepository()
sessionRepository = FakeSessionRepository()
transactionRepository = FakeTransactionRepository()
userRepository = FakeUserRepository()
}
fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication {
application {
moduleWithDependencies(
emailService = emailService,
metadataRepository = metadataRepository,
budgetRepository = budgetRepository,
categoryRepository = categoryRepository,
passwordResetRepository = passwordResetRepository,
permissionRepository = permissionRepository,
recurringTransactionRepository = recurringTransactionRepository,
sessionRepository = sessionRepository,
transactionRepository = transactionRepository,
userRepository = userRepository
)
}
val client = createClient {
install(ContentNegotiation) {
json()
}
}
test(client)
}
}

View file

@ -0,0 +1,423 @@
package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.BudgetRequest
import com.wbrawner.twigs.BudgetResponse
import com.wbrawner.twigs.UserPermissionRequest
import com.wbrawner.twigs.UserPermissionResponse
import com.wbrawner.twigs.model.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
class BudgetRouteTest : ApiTest() {
@Test
fun `fetching budgets requires authentication`() = apiTest { client ->
val response = client.get("/api/budgets")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `fetching budgets returns empty list when there are no budgets`() = apiTest { client ->
val session = Session()
sessionRepository.save(session)
val response = client.get("/api/budgets") {
header("Authorization", "Bearer ${session.token}")
}
assertEquals(HttpStatusCode.OK, response.status)
assertEquals(0, response.body<List<BudgetResponse>>().size)
}
@Test
fun `fetching budgets only returns budgets for current user`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val currentUserBudget = budgetRepository.save(Budget(name = "Test User's Budget"))
val otherUserBudget = budgetRepository.save(Budget(name = "Other User's Budget"))
permissionRepository.save(
UserPermission(
budgetId = currentUserBudget.id,
userId = users[0].id,
Permission.OWNER
)
)
permissionRepository.save(UserPermission(budgetId = otherUserBudget.id, userId = users[1].id, Permission.OWNER))
val response = client.get("/api/budgets") {
header("Authorization", "Bearer ${session.token}")
}
val returnedBudgets = response.body<List<BudgetResponse>>()
assertEquals(HttpStatusCode.OK, response.status)
assertEquals(1, returnedBudgets.size)
assertEquals(currentUserBudget.id, returnedBudgets.first().id)
}
@Test
fun `creating budgets requires authentication`() = apiTest { client ->
val request = BudgetRequest("Test Budget", "A budget for testing")
val response = client.post("/api/budgets") {
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `newly created budgets are saved`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermissionRequest(users[0].id, Permission.OWNER),
UserPermissionRequest(users[1].id, Permission.READ),
)
val request = BudgetRequest("Test Budget", "A budget for testing", permissions)
val response = client.post("/api/budgets") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.OK, response.status)
val responseBody: BudgetResponse = response.body()
assert(responseBody.id.isNotEmpty())
assertEquals("Test Budget", responseBody.name)
assertEquals("A budget for testing", responseBody.description)
assertEquals(2, responseBody.users.size)
assert(responseBody.users.containsAll(permissions.map { UserPermissionResponse(it.user, it.permission) }))
}
@Test
fun `newly created budgets include current user as owner if omitted`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermissionRequest(users[1].id, Permission.OWNER),
)
val request = BudgetRequest("Test Budget", "A budget for testing", permissions)
val response = client.post("/api/budgets") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.OK, response.status)
val responseBody: BudgetResponse = response.body()
assert(responseBody.id.isNotEmpty())
assertEquals("Test Budget", responseBody.name)
assertEquals("A budget for testing", responseBody.description)
assertEquals(2, responseBody.users.size)
val expectedPermissions = listOf(
UserPermissionResponse(users[0].id, Permission.OWNER),
UserPermissionResponse(users[1].id, Permission.OWNER),
)
assert(responseBody.users.containsAll(expectedPermissions))
}
@Test
fun `updating budgets requires authentication`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val request = BudgetRequest("Update Budget", "A budget for testing")
val response = client.put("/api/budgets/${existingBudget.id}") {
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `updating budgets returns forbidden for users with read only access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.READ),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val request = BudgetRequest("Update Budget", "A budget for testing")
val response = client.put("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun `updating budgets returns forbidden for users with write only access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.WRITE),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val request = BudgetRequest("Update Budget", "A budget for testing")
val response = client.put("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun `updating budgets returns success for users with manage access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val request = BudgetRequest("Update Budget", "An update budget for testing")
val response = client.put("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.OK, response.status)
val updatedBudget: BudgetResponse = response.body()
assertEquals(request.name, updatedBudget.name)
assertEquals(request.description, updatedBudget.description)
val expectedUsers = permissions.map { UserPermissionResponse(it.userId, it.permission) }
val updatedUsers = updatedBudget.users
assertEquals(expectedUsers, updatedUsers)
}
@Disabled("Will be fixed with service layer refactor")
@Test
fun `updating budgets returns not found for users with no access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val request = BudgetRequest("Update Budget", "An update budget for testing")
val response = client.put("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Disabled("Will be fixed with service layer refactor")
@Test
fun `updating non-existent budgets returns not found`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val request = BudgetRequest("Update Budget", "An update budget for testing")
val response = client.put("/api/budgets/random-budget-id") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Disabled("Will be fixed with service layer refactor")
@Test
fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() =
apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget =
budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val request = BudgetRequest(
"Update Budget",
"An update budget for testing",
setOf(
UserPermissionRequest(users[0].id, Permission.OWNER),
UserPermissionRequest(users[0].id, Permission.MANAGE),
)
)
val response = client.put("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun `deleting budgets requires authentication`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val request = BudgetRequest("Update Budget", "A budget for testing")
val response = client.put("/api/budgets/${existingBudget.id}") {
header("Content-Type", "application/json")
setBody(request)
}
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `deleting budgets returns forbidden for users with read only access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.READ),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val response = client.delete("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun `deleting budgets returns forbidden for users with write only access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.WRITE),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val response = client.delete("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun `deleting budgets returns forbidden for users with manage access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val response = client.delete("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun `deleting budgets returns success for users with owner access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
)
users.forEach { userRepository.save(it) }
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
val session = Session(userId = users.first().id)
sessionRepository.save(session)
val permissions = setOf(
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.OWNER),
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
)
permissions.forEach {
permissionRepository.save(it)
}
val response = client.delete("/api/budgets/${existingBudget.id}") {
header("Authorization", "Bearer ${session.token}")
header("Content-Type", "application/json")
}
assertEquals(HttpStatusCode.NoContent, response.status)
}
}

View file

@ -32,6 +32,10 @@ class JdbcPermissionRepository(dataSource: DataSource) :
conn.executeQuery(sql.toString(), params) conn.executeQuery(sql.toString(), params)
} }
override suspend fun findAll(ids: List<String>?): List<UserPermission> {
throw UnsupportedOperationException("UserPermission requires a userId and budgetId")
}
override fun ResultSet.toEntity(): UserPermission = UserPermission( override fun ResultSet.toEntity(): UserPermission = UserPermission(
budgetId = getString(Fields.BUDGET_ID.name.lowercase()), budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
userId = getString(Fields.USER_ID.name.lowercase()), userId = getString(Fields.USER_ID.name.lowercase()),

View file

@ -1,16 +1,21 @@
package com.wbrawner.twigs.db package com.wbrawner.twigs.db
import com.wbrawner.twigs.storage.Repository
import java.sql.ResultSet import java.sql.ResultSet
import java.sql.SQLException import java.sql.SQLException
import javax.sql.DataSource import javax.sql.DataSource
class MetadataRepository(dataSource: DataSource) : interface MetadataRepository : Repository<DatabaseMetadata> {
JdbcRepository<DatabaseMetadata, MetadataRepository.Fields>(dataSource) { fun runMigration(toVersion: Int)
}
class JdbcMetadataRepository(dataSource: DataSource) :
JdbcRepository<DatabaseMetadata, JdbcMetadataRepository.Fields>(dataSource), MetadataRepository {
override val tableName: String = TABLE_METADATA override val tableName: String = TABLE_METADATA
override val fields: Map<Fields, (DatabaseMetadata) -> Any?> = Fields.values().associateWith { it.entityField } override val fields: Map<Fields, (DatabaseMetadata) -> Any?> = Fields.values().associateWith { it.entityField }
override val conflictFields: Collection<String> = listOf() override val conflictFields: Collection<String> = listOf()
suspend fun runMigration(toVersion: Int) { override fun runMigration(toVersion: Int) {
val queries = MetadataRepository::class.java val queries = MetadataRepository::class.java
.getResource("/sql/$toVersion.sql") .getResource("/sql/$toVersion.sql")
?.readText() ?.readText()

View file

@ -2,9 +2,9 @@
bcrypt = "0.9.0" bcrypt = "0.9.0"
hikari = "5.0.1" hikari = "5.0.1"
junit = "5.8.2" junit = "5.8.2"
kotlin = "1.6.21" kotlin = "1.9.10"
kotlinx-coroutines = "1.6.2" kotlinx-coroutines = "1.6.2"
ktor = "2.0.2" ktor = "2.3.4"
logback = "1.2.11" logback = "1.2.11"
mail = "1.6.2" mail = "1.6.2"
postgres = "42.3.8" postgres = "42.3.8"
@ -18,8 +18,10 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
@ -28,6 +30,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" } ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
mail = { module = "com.sun.mail:javax.mail", version.ref = "mail" } mail = { module = "com.sun.mail:javax.mail", version.ref = "mail" }
postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" }

View file

@ -8,5 +8,12 @@ dependencies {
implementation(project(":storage")) implementation(project(":storage"))
api(libs.kotlinx.coroutines.test) api(libs.kotlinx.coroutines.test)
api(libs.junit.jupiter.api) api(libs.junit.jupiter.api)
implementation(project(mapOf("path" to ":db")))
runtimeOnly(libs.junit.jupiter.engine) runtimeOnly(libs.junit.jupiter.engine)
} }
tasks {
test {
useJUnitPlatform()
}
}

View file

@ -0,0 +1,14 @@
package com.wbrawner.twigs.test.helpers
import com.wbrawner.twigs.EmailService
import com.wbrawner.twigs.model.PasswordResetToken
class FakeEmailService : EmailService {
val emails = mutableListOf<FakeEmail<*>>()
override fun sendPasswordResetEmail(token: PasswordResetToken, to: String) {
emails.add(FakeEmail(to, token))
}
}
data class FakeEmail<Data>(val to: String, val data: Data)

View file

@ -0,0 +1,6 @@
package com.wbrawner.twigs.test.helpers.repository
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.storage.BudgetRepository
class FakeBudgetRepository : FakeRepository<Budget>(), BudgetRepository

View file

@ -0,0 +1,18 @@
package com.wbrawner.twigs.test.helpers.repository
import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.storage.CategoryRepository
class FakeCategoryRepository : FakeRepository<Category>(), CategoryRepository {
override fun findAll(
budgetIds: List<String>,
ids: List<String>?,
expense: Boolean?,
archived: Boolean?
): List<Category> = entities.filter {
budgetIds.contains(it.budgetId)
&& ids?.contains(it.id) ?: true
&& it.expense == (expense ?: it.expense)
&& it.archived == (archived ?: it.archived)
}
}

View file

@ -0,0 +1,21 @@
package com.wbrawner.twigs.test.helpers.repository
import com.wbrawner.twigs.db.DatabaseMetadata
import com.wbrawner.twigs.db.MetadataRepository
import com.wbrawner.twigs.storage.Repository
class FakeMetadataRepository : Repository<DatabaseMetadata>, MetadataRepository {
var metadata = DatabaseMetadata()
override fun runMigration(toVersion: Int) {
metadata = metadata.copy(version = toVersion)
}
override suspend fun findAll(ids: List<String>?): List<DatabaseMetadata> = listOf(metadata)
override suspend fun delete(item: DatabaseMetadata): Boolean = false
override suspend fun save(item: DatabaseMetadata): DatabaseMetadata {
metadata = item
return metadata
}
}

View file

@ -0,0 +1,6 @@
package com.wbrawner.twigs.test.helpers.repository
import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.storage.PasswordResetRepository
class FakePasswordResetRepository : FakeRepository<PasswordResetToken>(), PasswordResetRepository

View file

@ -0,0 +1,26 @@
package com.wbrawner.twigs.test.helpers.repository
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.storage.PermissionRepository
class FakePermissionRepository : PermissionRepository {
val permissions: MutableList<UserPermission> = mutableListOf()
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
permissions.filter { userPermission ->
budgetIds?.contains(userPermission.budgetId) ?: true
&& userId?.let { it == userPermission.userId } ?: true
}
override suspend fun findAll(ids: List<String>?): List<UserPermission> {
throw UnsupportedOperationException("UserPermission requires a userId and budgetId")
}
override suspend fun save(item: UserPermission): UserPermission {
permissions.removeIf { it.budgetId == item.budgetId && it.userId == item.userId }
permissions.add(item)
return item
}
override suspend fun delete(item: UserPermission): Boolean =
permissions.removeIf { it.budgetId == item.budgetId && it.userId == item.userId }
}

View file

@ -4,7 +4,7 @@ import com.wbrawner.twigs.model.RecurringTransaction
import com.wbrawner.twigs.storage.RecurringTransactionRepository import com.wbrawner.twigs.storage.RecurringTransactionRepository
import java.time.Instant import java.time.Instant
class FakeRecurringTransactionsRepository : FakeRepository<RecurringTransaction>(), RecurringTransactionRepository { class FakeRecurringTransactionRepository : FakeRepository<RecurringTransaction>(), RecurringTransactionRepository {
override suspend fun findAll(now: Instant): List<RecurringTransaction> = entities.filter { override suspend fun findAll(now: Instant): List<RecurringTransaction> = entities.filter {
(it.start == now || it.start.isBefore(now)) && it.finish?.isAfter(now) ?: true (it.start == now || it.start.isBefore(now)) && it.finish?.isAfter(now) ?: true
} }

View file

@ -0,0 +1,15 @@
package com.wbrawner.twigs.test.helpers.repository
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.storage.SessionRepository
import java.util.function.Predicate
class FakeSessionRepository : FakeRepository<Session>(), SessionRepository {
var expirationPredicate: Predicate<in Session> = Predicate { false }
override fun findAll(token: String): List<Session> = entities.filter { it.token == token }
override fun deleteExpired() {
entities.removeIf(expirationPredicate)
}
}

View file

@ -0,0 +1,19 @@
package com.wbrawner.twigs.test.helpers.repository
import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.storage.UserRepository
class FakeUserRepository : FakeRepository<User>(), UserRepository {
override fun findAll(nameOrEmail: String, password: String?): List<User> {
return entities.filter {
(it.name.equals(nameOrEmail, ignoreCase = true) || it.email.equals(
nameOrEmail,
ignoreCase = true
)) && it.password == password
}
}
override fun findAll(nameLike: String): List<User> {
return entities.filter { it.name.contains(nameLike, ignoreCase = true) }
}
}