-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
b10fa56
commit 3bd43a0
Showing
9 changed files
with
638 additions
and
62 deletions.
There are no files selected for viewing
9 changes: 8 additions & 1 deletion
9
...kit/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationContext.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,13 @@ | ||
package tech.coner.trailer.toolkit.validation | ||
|
||
interface ValidationContext<FEEDBACK : Feedback> { | ||
import kotlin.reflect.KProperty1 | ||
|
||
interface ValidationContext<INPUT, FEEDBACK : Feedback> { | ||
|
||
fun give(feedback: FEEDBACK) | ||
|
||
fun <PROPERTY> on( | ||
property: KProperty1<INPUT, PROPERTY>, | ||
function: ValidationContext<PROPERTY, FEEDBACK>.(PROPERTY) -> FEEDBACK? | ||
) | ||
} |
8 changes: 5 additions & 3 deletions
8
toolkit/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationResult.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,17 @@ | ||
package tech.coner.trailer.toolkit.validation | ||
|
||
import kotlin.reflect.KProperty1 | ||
|
||
data class ValidationResult<FEEDBACK : Feedback>( | ||
val feedback: List<FEEDBACK> | ||
val feedback: Map<KProperty1<*, *>?, List<FEEDBACK>> | ||
) { | ||
val isValid: Boolean by lazy { | ||
feedback.isEmpty() | ||
|| feedback.all { it.severity.valid } | ||
|| feedback.values.all { it.all { feedback -> feedback.severity.valid } } | ||
} | ||
|
||
val isInvalid: Boolean by lazy { | ||
feedback.isNotEmpty() | ||
&& feedback.any { !it.severity.valid } | ||
&& feedback.values.any { it.any { feedback -> !feedback.severity.valid } } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
...lidation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/InternalExtensions.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package tech.coner.trailer.toolkit.validation.impl | ||
|
||
internal fun <K, I> MutableMap<K?, MutableList<I>>.createOrAppend(key: K, item: I) { | ||
get(key) | ||
?.also { it.add(item) } | ||
?: mutableListOf(item) | ||
.also { put(key, it) } | ||
} | ||
|
||
internal fun <K, I> MutableMap<K?, MutableList<I>>.createOrAppend(source: Map<K?, List<I>>) { | ||
source.forEach { (key, keyItems) -> | ||
get(key) | ||
?.also { it.addAll(keyItems) } | ||
?: keyItems.toMutableList() | ||
.also { put(key, it) } | ||
} | ||
} |
23 changes: 19 additions & 4 deletions
23
...ation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/ValidationContextImpl.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,28 @@ | ||
package tech.coner.trailer.toolkit.validation.impl | ||
|
||
import tech.coner.trailer.toolkit.validation.ValidationContext | ||
import tech.coner.trailer.toolkit.validation.Feedback | ||
import tech.coner.trailer.toolkit.validation.ValidationContext | ||
import kotlin.reflect.KProperty1 | ||
|
||
internal class ValidationContextImpl<FEEDBACK : Feedback> : ValidationContext<FEEDBACK> { | ||
internal class ValidationContextImpl<INPUT, FEEDBACK : Feedback>( | ||
private val property: KProperty1<*, *>? = null, | ||
private val input: INPUT | ||
) : ValidationContext<INPUT, FEEDBACK> { | ||
|
||
val feedback = mutableListOf<FEEDBACK>() | ||
val feedback = mutableMapOf<KProperty1<*, *>?, MutableList<FEEDBACK>>() | ||
|
||
override fun give(feedback: FEEDBACK) { | ||
this.feedback += feedback | ||
this.feedback.createOrAppend(property, feedback) | ||
} | ||
|
||
override fun <PROPERTY> on( | ||
property: KProperty1<INPUT, PROPERTY>, | ||
function: ValidationContext<PROPERTY, FEEDBACK>.(PROPERTY) -> FEEDBACK? | ||
) { | ||
val propertyValue = property.get(input) | ||
val propertyContext = ValidationContextImpl<PROPERTY, FEEDBACK>(property, propertyValue) | ||
function(propertyContext, propertyValue) | ||
?.also { propertyContext.give(it) } | ||
feedback.createOrAppend(propertyContext.feedback) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
231 changes: 231 additions & 0 deletions
231
.../src/test/kotlin/tech/coner/trailer/toolkit/validation/ChangePasswordFormValidatorTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
package tech.coner.trailer.toolkit.validation | ||
|
||
import assertk.all | ||
import assertk.assertThat | ||
import assertk.assertions.doesNotContainKey | ||
import assertk.assertions.isEqualTo | ||
import assertk.assertions.key | ||
import org.junit.jupiter.params.ParameterizedTest | ||
import org.junit.jupiter.params.provider.EnumSource | ||
import tech.coner.trailer.toolkit.validation.ChangePasswordFormFeedback.* | ||
import tech.coner.trailer.toolkit.validation.PasswordPolicy.Factory.anyOneChar | ||
import tech.coner.trailer.toolkit.validation.PasswordPolicy.Factory.irritating | ||
import tech.coner.trailer.toolkit.validation.Severity.Error | ||
import tech.coner.trailer.toolkit.validation.Severity.Warning | ||
|
||
class ChangePasswordFormValidatorTest { | ||
|
||
enum class ChangePasswordFormScenario( | ||
val input: ChangePasswordFormState, | ||
val expectedCurrentPasswordFeedback: List<ChangePasswordFormFeedback>? = null, | ||
val expectedNewPasswordFeedback: List<ChangePasswordFormFeedback>? = null, | ||
val expectedNewPasswordRepeatedFeedback: List<ChangePasswordFormFeedback>? = null, | ||
val expectedIsValid: Boolean | ||
) { | ||
EMPTY_ANY_ONE_CHAR_INVALID( | ||
input = ChangePasswordFormState( | ||
passwordPolicy = anyOneChar(), | ||
currentPassword = "", | ||
newPassword = "", | ||
newPasswordRepeated = "" | ||
), | ||
expectedCurrentPasswordFeedback = listOf(MustNotBeEmpty), | ||
expectedNewPasswordFeedback = listOf(InsufficientLength(Error)), | ||
expectedIsValid = false | ||
), | ||
MINIMUM_ANY_ONE_CHAR_VALID( | ||
input = ChangePasswordFormState( | ||
passwordPolicy = anyOneChar(), | ||
currentPassword = "a", | ||
newPassword = "b", | ||
newPasswordRepeated = "b" | ||
), | ||
expectedIsValid = true | ||
), | ||
SAME_ANY_ONE_CHAR_INVALID( | ||
input = ChangePasswordFormState( | ||
passwordPolicy = anyOneChar(), | ||
currentPassword = "a", | ||
newPassword = "a", | ||
newPasswordRepeated = "a" | ||
), | ||
expectedNewPasswordFeedback = listOf(NewPasswordSameAsCurrentPassword), | ||
expectedIsValid = false | ||
), | ||
REPEAT_MISMATCH_ANY_ONE_CHAR_INVALID( | ||
input = ChangePasswordFormState( | ||
passwordPolicy = anyOneChar(), | ||
currentPassword = "a", | ||
newPassword = "b", | ||
newPasswordRepeated = "c" | ||
), | ||
expectedNewPasswordRepeatedFeedback = listOf(RepeatPasswordMismatch), | ||
expectedIsValid = false | ||
), | ||
IRRITATING_INVALID( | ||
input = ChangePasswordFormState( | ||
passwordPolicy = irritating(), | ||
currentPassword = "a", | ||
newPassword = "aA1!", | ||
newPasswordRepeated = "aA1!" | ||
), | ||
expectedNewPasswordFeedback = listOf( | ||
InsufficientLength(Error), | ||
InsufficientLetterLowercase(Warning), | ||
InsufficientLetterUppercase(Warning), | ||
InsufficientNumeric(Warning), | ||
InsufficientSpecial(Warning) | ||
), | ||
expectedIsValid = false | ||
), | ||
IRRITATING_VALID_WITH_WARNINGS( | ||
input = ChangePasswordFormState( | ||
passwordPolicy = irritating(), | ||
currentPassword = "a", | ||
newPassword = "Tr0ub4dor&3", | ||
newPasswordRepeated = "Tr0ub4dor&3" | ||
), | ||
expectedNewPasswordFeedback = listOf( | ||
InsufficientLength(Warning), | ||
InsufficientLetterUppercase(Warning), | ||
InsufficientSpecial(Warning) | ||
), | ||
expectedIsValid = true | ||
), | ||
IRRITATING_HARD_TO_REMEMBER_EASY_GUESS_FOR_COMPUTER( | ||
input = ChangePasswordFormState( | ||
passwordPolicy = irritating(), | ||
currentPassword = "a", | ||
newPassword = "battery horse staple correct", | ||
newPasswordRepeated = "battery horse staple correct" | ||
), | ||
expectedNewPasswordFeedback = listOf( | ||
InsufficientLetterUppercase(Error), | ||
InsufficientNumeric(Error) | ||
), | ||
expectedIsValid = false | ||
) | ||
|
||
} | ||
|
||
@ParameterizedTest | ||
@EnumSource | ||
fun itShouldValidateChangePasswordForm(scenario: ChangePasswordFormScenario) { | ||
val actual = changePasswordFormValidator(scenario.input) | ||
|
||
assertThat(actual).all { | ||
feedback().all { | ||
when (scenario.expectedCurrentPasswordFeedback) { | ||
is List<ChangePasswordFormFeedback> -> key(ChangePasswordFormState::currentPassword).isEqualTo(scenario.expectedCurrentPasswordFeedback) | ||
else -> doesNotContainKey(ChangePasswordFormState::currentPassword) | ||
} | ||
when (scenario.expectedNewPasswordFeedback) { | ||
is List<ChangePasswordFormFeedback> -> key(ChangePasswordFormState::newPassword).isEqualTo(scenario.expectedNewPasswordFeedback) | ||
else -> doesNotContainKey(ChangePasswordFormState::newPassword) | ||
} | ||
when (scenario.expectedNewPasswordRepeatedFeedback) { | ||
is List<ChangePasswordFormFeedback> -> key(ChangePasswordFormState::newPasswordRepeated).isEqualTo(scenario.expectedNewPasswordRepeatedFeedback) | ||
else -> doesNotContainKey((ChangePasswordFormState::newPasswordRepeated)) | ||
} | ||
} | ||
isValid().isEqualTo(scenario.expectedIsValid) | ||
isInvalid().isEqualTo(!scenario.expectedIsValid) | ||
} | ||
} | ||
} | ||
|
||
data class PasswordPolicy( | ||
val lengthThreshold: MinimumThreshold, | ||
val letterLowercaseThreshold: MinimumThreshold, | ||
val letterUppercaseThreshold: MinimumThreshold, | ||
val numericThreshold: MinimumThreshold, | ||
val specialThreshold: MinimumThreshold, | ||
) { | ||
data class MinimumThreshold( | ||
val minForError: Int, | ||
val minForWarning: Int, | ||
) | ||
|
||
object Factory { | ||
fun anyOneChar(): PasswordPolicy { | ||
val zeroLengthAllowed = MinimumThreshold(minForError = 0, minForWarning = 0) | ||
val oneLengthRequired = MinimumThreshold(minForError = 1, minForWarning = 0) | ||
return zeroLengthAllowed.let { | ||
PasswordPolicy(oneLengthRequired, it, it, it, it) | ||
} | ||
} | ||
fun irritating(): PasswordPolicy { | ||
val length = MinimumThreshold(minForError = 8, minForWarning = 12) | ||
val encourageComplexity = MinimumThreshold(minForError = 1, minForWarning = 2) | ||
return encourageComplexity.let { | ||
PasswordPolicy(length, it, it, it, it) | ||
} | ||
} | ||
} | ||
} | ||
|
||
data class ChangePasswordFormState( | ||
val passwordPolicy: PasswordPolicy, | ||
val currentPassword: String, | ||
val newPassword: String, | ||
val newPasswordRepeated: String | ||
) | ||
|
||
sealed class ChangePasswordFormFeedback : Feedback { | ||
data object MustNotBeEmpty : ChangePasswordFormFeedback() { | ||
override val severity = Error | ||
} | ||
data object NewPasswordSameAsCurrentPassword : ChangePasswordFormFeedback() { | ||
override val severity = Error | ||
} | ||
data class InsufficientLength(override val severity: Severity) : ChangePasswordFormFeedback() | ||
data class InsufficientLetterLowercase(override val severity: Severity) : ChangePasswordFormFeedback() | ||
data class InsufficientLetterUppercase(override val severity: Severity) : ChangePasswordFormFeedback() | ||
data class InsufficientNumeric(override val severity: Severity) : ChangePasswordFormFeedback() | ||
data class InsufficientSpecial(override val severity: Severity) : ChangePasswordFormFeedback() | ||
data object RepeatPasswordMismatch : ChangePasswordFormFeedback() { | ||
override val severity = Error | ||
} | ||
} | ||
|
||
private val changePasswordFormValidator = Validator<ChangePasswordFormState, ChangePasswordFormFeedback> { state -> | ||
operator fun PasswordPolicy.MinimumThreshold.invoke( | ||
value: Int, | ||
feedbackFn: (Severity) -> ChangePasswordFormFeedback | ||
): ChangePasswordFormFeedback? { | ||
val severity = when { | ||
value < minForError -> Error | ||
value < minForWarning -> Warning | ||
else -> null | ||
} | ||
return severity?.let(feedbackFn) | ||
} | ||
on(ChangePasswordFormState::currentPassword) { | ||
if (it.isEmpty()) MustNotBeEmpty | ||
else null | ||
} | ||
on(ChangePasswordFormState::newPassword) { | ||
if (state.currentPassword.isNotEmpty() && it == state.currentPassword) NewPasswordSameAsCurrentPassword | ||
else null | ||
} | ||
on(ChangePasswordFormState::newPassword) { | ||
state.passwordPolicy.lengthThreshold(it.length, ::InsufficientLength) | ||
} | ||
on(ChangePasswordFormState::newPassword) { | ||
state.passwordPolicy.letterLowercaseThreshold(it.count { char -> char.isLowerCase() }, ::InsufficientLetterLowercase) | ||
} | ||
on(ChangePasswordFormState::newPassword) { | ||
state.passwordPolicy.letterUppercaseThreshold(it.count { char -> char.isUpperCase() }, ::InsufficientLetterUppercase) | ||
} | ||
on(ChangePasswordFormState::newPassword) { | ||
state.passwordPolicy.numericThreshold(it.count { char -> char.isDigit() }, ::InsufficientNumeric) | ||
} | ||
on(ChangePasswordFormState::newPassword) { | ||
state.passwordPolicy.specialThreshold(it.count { char -> !char.isLetterOrDigit() }, ::InsufficientSpecial) | ||
} | ||
on(ChangePasswordFormState::newPasswordRepeated) { | ||
if (state.newPassword != it) RepeatPasswordMismatch | ||
else null | ||
} | ||
null | ||
} |
Oops, something went wrong.