Skip to content

Commit e815323

Browse files
committed
retry opprettelse av feedback kanal, med unique constraint på external id
1 parent ddc609d commit e815323

10 files changed

Lines changed: 264 additions & 111 deletions

File tree

core/src/test/kotlin/no/javazone/feedback/domain/adapters/FeedbackAdapterTest.kt

Lines changed: 0 additions & 70 deletions
This file was deleted.

database/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ kotlin {
1212
jvmToolchain(25)
1313
}
1414

15+
tasks.test {
16+
useJUnitPlatform()
17+
}
18+
1519
dependencies {
1620
implementation(project(":domain"))
1721

1822
implementation(libs.bundles.database)
1923
implementation(libs.bundles.testcontainers)
24+
25+
testImplementation(libs.junit.jupiter)
26+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
2027
}
2128

database/src/main/kotlin/no/javazone/feedback/database/repository/FeedbackRepositoryDb.kt

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,49 +8,58 @@ import no.javazone.feedback.domain.Feedback
88
import no.javazone.feedback.domain.FeedbackChannel
99
import no.javazone.feedback.domain.FeedbackChannelRatingCategory
1010
import no.javazone.feedback.domain.FeedbackRating
11+
import no.javazone.feedback.domain.errors.ExternalIdAlreadyExistsError
1112
import no.javazone.feedback.domain.persistence.FeedbackRepository
13+
import org.jetbrains.exposed.exceptions.ExposedSQLException
1214
import org.jetbrains.exposed.sql.JoinType
1315
import org.jetbrains.exposed.sql.batchInsert
1416
import org.jetbrains.exposed.sql.insertReturning
1517
import org.jetbrains.exposed.sql.selectAll
1618
import org.jetbrains.exposed.sql.transactions.transaction
19+
import org.postgresql.util.PSQLState
1720
import java.time.Instant
1821

1922
object FeedbackRepositoryDb : FeedbackRepository {
2023
override fun intializeChannel(channel: FeedbackChannel): FeedbackChannel {
21-
return transaction {
22-
val createdChannelId = FeedbackChannels.insertReturning {
23-
it[title] = channel.title
24-
it[speakers] = channel.speakers
25-
it[externalId] = channel.externalId
26-
}.map {
27-
it[FeedbackChannels.id]
28-
}.first()
24+
try {
25+
return transaction {
26+
val createdChannelId = FeedbackChannels.insertReturning {
27+
it[title] = channel.title
28+
it[speakers] = channel.speakers
29+
it[externalId] = channel.externalId
30+
}.map {
31+
it[FeedbackChannels.id]
32+
}.first()
2933

30-
val ratingCategories = RatingTypes.batchInsert(channel.ratingCategories) { rating ->
31-
this[RatingTypes.channelId] = createdChannelId.value
32-
this[RatingTypes.ratingName] = rating.name
33-
this[RatingTypes.createdAt] = Instant.now()
34-
}.map {
35-
FeedbackChannelRatingCategory(
36-
id = it[RatingTypes.id].value,
37-
name = it[RatingTypes.ratingName]
38-
)
39-
}
34+
val ratingCategories = RatingTypes.batchInsert(channel.ratingCategories) { rating ->
35+
this[RatingTypes.channelId] = createdChannelId.value
36+
this[RatingTypes.ratingName] = rating.name
37+
this[RatingTypes.createdAt] = Instant.now()
38+
}.map {
39+
FeedbackChannelRatingCategory(
40+
id = it[RatingTypes.id].value,
41+
name = it[RatingTypes.ratingName]
42+
)
43+
}
4044

41-
FeedbackChannels.selectAll().where {
42-
FeedbackChannels.id eq createdChannelId
43-
}.map {
44-
FeedbackChannel(
45-
id = it[FeedbackChannels.id].value,
46-
title = it[FeedbackChannels.title],
47-
speakers = it[FeedbackChannels.speakers],
48-
externalId = it[FeedbackChannels.externalId],
49-
ratingCategories = ratingCategories
50-
)
51-
}.first()
45+
FeedbackChannels.selectAll().where {
46+
FeedbackChannels.id eq createdChannelId
47+
}.map {
48+
FeedbackChannel(
49+
id = it[FeedbackChannels.id].value,
50+
title = it[FeedbackChannels.title],
51+
speakers = it[FeedbackChannels.speakers],
52+
externalId = it[FeedbackChannels.externalId],
53+
ratingCategories = ratingCategories
54+
)
55+
}.first()
56+
}
57+
} catch (e: ExposedSQLException) {
58+
if (e.sqlState == PSQLState.UNIQUE_VIOLATION.state) {
59+
throw ExternalIdAlreadyExistsError(channel.externalId, cause = e)
60+
}
61+
throw e
5262
}
53-
5463
}
5564

5665
override fun submitFeedback(feedback: Feedback, feedbackChannel: FeedbackChannel): Feedback {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
--liquibase formatted sql
2+
3+
--changeset tanettrimas:8
4+
ALTER TABLE feedback_channel ADD CONSTRAINT uq_feedback_channel_external_id UNIQUE (external_id);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package no.javazone.feedback.database.repository
2+
3+
import no.javazone.feedback.database.TestDatabase
4+
import no.javazone.feedback.database.setupDatabase
5+
import no.javazone.feedback.domain.FeedbackChannel
6+
import no.javazone.feedback.domain.FeedbackChannelRatingCategory
7+
import no.javazone.feedback.domain.errors.ExternalIdAlreadyExistsError
8+
import org.jetbrains.exposed.exceptions.ExposedSQLException
9+
import org.junit.jupiter.api.AfterAll
10+
import org.junit.jupiter.api.Assertions.assertEquals
11+
import org.junit.jupiter.api.Assertions.assertInstanceOf
12+
import org.junit.jupiter.api.Assertions.assertNotNull
13+
import org.junit.jupiter.api.BeforeAll
14+
import org.junit.jupiter.api.Test
15+
import org.junit.jupiter.api.assertThrows
16+
17+
class FeedbackRepositoryDbTest {
18+
companion object {
19+
@BeforeAll
20+
@JvmStatic
21+
fun setup() {
22+
TestDatabase.start()
23+
setupDatabase(TestDatabase.config())
24+
}
25+
26+
@AfterAll
27+
@JvmStatic
28+
fun tearDown() {
29+
TestDatabase.stop()
30+
}
31+
}
32+
33+
@Test
34+
fun `should throw ExternalIdAlreadyExistsError when inserting channel with duplicate external id`() {
35+
val channel = FeedbackChannel(
36+
title = "Kotlin Workshop",
37+
speakers = listOf("Alice"),
38+
externalId = "DUPE",
39+
ratingCategories = listOf(
40+
FeedbackChannelRatingCategory(name = "Content")
41+
)
42+
)
43+
44+
FeedbackRepositoryDb.intializeChannel(channel)
45+
46+
val duplicateChannel = FeedbackChannel(
47+
title = "Another Workshop",
48+
speakers = listOf("Bob"),
49+
externalId = "DUPE",
50+
ratingCategories = listOf(
51+
FeedbackChannelRatingCategory(name = "Delivery")
52+
)
53+
)
54+
55+
val exception = assertThrows<ExternalIdAlreadyExistsError> {
56+
FeedbackRepositoryDb.intializeChannel(duplicateChannel)
57+
}
58+
59+
assertEquals("Channel with external id DUPE already exists.", exception.message)
60+
assertNotNull(exception.cause)
61+
assertInstanceOf(ExposedSQLException::class.java, exception.cause)
62+
}
63+
}

domain/src/main/kotlin/no/javazone/feedback/domain/adapters/FeedbackAdapter.kt

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,34 @@ import no.javazone.feedback.domain.FeedbackChannel
55
import no.javazone.feedback.domain.FeedbackChannelCreationInput
66
import no.javazone.feedback.domain.FeedbackWithChannel
77
import no.javazone.feedback.domain.errors.ChannelNotFoundError
8+
import no.javazone.feedback.domain.errors.ExternalIdAlreadyExistsError
9+
import no.javazone.feedback.domain.errors.ExternalIdGenerationException
810
import no.javazone.feedback.domain.generators.ExternalIdGenerator
911
import no.javazone.feedback.domain.persistence.FeedbackRepository
1012

1113
class FeedbackAdapter(
1214
private val repository: FeedbackRepository,
1315
private val externalIdGenerator: ExternalIdGenerator
1416
) {
17+
companion object {
18+
private const val MAX_RETRIES = 3
19+
}
20+
1521
fun createFeedbackChannel(input: FeedbackChannelCreationInput): FeedbackChannel {
16-
val channel = FeedbackChannel(
17-
title = input.title,
18-
speakers = input.speakers,
19-
externalId = externalIdGenerator.generate(),
20-
ratingCategories = input.ratings
21-
)
22-
return repository.intializeChannel(channel)
22+
repeat(MAX_RETRIES) {
23+
try {
24+
val channel = FeedbackChannel(
25+
title = input.title,
26+
speakers = input.speakers,
27+
externalId = externalIdGenerator.generate(),
28+
ratingCategories = input.ratings
29+
)
30+
return repository.intializeChannel(channel)
31+
} catch (_: ExternalIdAlreadyExistsError) {
32+
// retry with a new external id
33+
}
34+
}
35+
throw ExternalIdGenerationException()
2336
}
2437

2538
fun submitFeedback(channelId: String, feedback: Feedback): FeedbackWithChannel {
Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
package no.javazone.feedback.domain.errors
22

3-
sealed class DomainErrors : Exception()
3+
sealed class DomainErrors(
4+
override val message: String,
5+
cause: Throwable? = null
6+
) : RuntimeException(message, cause)
47

5-
class ChannelNotFoundError(channelId: String) : DomainErrors() {
6-
override val message: String = "Channel with id $channelId not found."
7-
}
8+
class ChannelNotFoundError(channelId: String) :
9+
DomainErrors("Channel with id $channelId not found.")
10+
11+
class ExternalIdAlreadyExistsError(externalId: String, cause: Throwable? = null) :
12+
DomainErrors("Channel with external id $externalId already exists.", cause)
13+
14+
class ExternalIdGenerationException :
15+
DomainErrors("Failed to generate a unique external id after multiple attempts.")
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package no.javazone.feedback.domain
2+
3+
import no.javazone.feedback.domain.errors.ExternalIdAlreadyExistsError
4+
import no.javazone.feedback.domain.persistence.FeedbackRepository
5+
6+
internal class FakeFeedbackRepository(
7+
private val existingExternalIds: MutableSet<String> = mutableSetOf()
8+
) : FeedbackRepository {
9+
private val channels = mutableMapOf<String, FeedbackChannel>()
10+
11+
override fun intializeChannel(channel: FeedbackChannel): FeedbackChannel {
12+
if (channel.externalId in existingExternalIds) {
13+
throw ExternalIdAlreadyExistsError(channel.externalId)
14+
}
15+
existingExternalIds.add(channel.externalId)
16+
channels[channel.externalId] = channel
17+
return channel
18+
}
19+
20+
override fun submitFeedback(feedback: Feedback, feedbackChannel: FeedbackChannel): Feedback {
21+
return Feedback(id = 1, comment = feedback.comment, ratings = feedback.ratings)
22+
}
23+
24+
override fun findByChannelId(channelId: String): FeedbackChannel? {
25+
return channels[channelId]
26+
}
27+
28+
override fun findAllChannels(): List<FeedbackChannel> {
29+
return channels.values.toList()
30+
}
31+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package no.javazone.feedback.domain
2+
3+
import no.javazone.feedback.domain.generators.ExternalIdGenerator
4+
5+
internal class SequentialIdGenerator(vararg ids: String) : ExternalIdGenerator {
6+
private val iterator = ids.iterator()
7+
8+
override fun generate(): String {
9+
return iterator.next()
10+
}
11+
}

0 commit comments

Comments
 (0)