diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt index 1ee845f..e7ef07c 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt @@ -5,8 +5,13 @@ import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId -fun interface InfraredKeyIdGenerator { fun nextId(): InfraredKeyId } -fun interface EpochMillisClock { fun now(): Long } +fun interface InfraredKeyIdGenerator { + fun nextId(): InfraredKeyId +} + +fun interface EpochMillisClock { + fun now(): Long +} class InfraredKeyFactory( private val idGenerator: InfraredKeyIdGenerator, @@ -18,16 +23,22 @@ class InfraredKeyFactory( displayName: String, ): DomainResult { val normalizedName = displayName.trim() - if (profileId.value.isBlank() || deviceName.value.isBlank() || normalizedName.isBlank()) { + if ( + profileId.value.isBlank() || + profileId.value != profileId.value.trim() || + deviceName.value.isBlank() || + deviceName.value != deviceName.value.trim() || + normalizedName.isBlank() + ) { return DomainResult.Failure( DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"), ) } val id = idGenerator.nextId() - val normalizedId = id.value.lowercase().filter { it in 'a'..'z' || it in '0'..'9' } - if (normalizedId.isBlank()) { - return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key id is invalid")) - } + val canonicalId = canonicalizeId(id.value) + ?: return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key id is invalid"), + ) val now = clock.now() return DomainResult.Success( SavedInfraredKey( @@ -35,7 +46,7 @@ class InfraredKeyFactory( profileId = profileId, deviceName = deviceName, displayName = normalizedName, - sdkKey = "app_ir_${normalizedId.take(40)}", + sdkKey = "app_ir_$canonicalId", status = InfraredKeyStatus.LEARNING, lastErrorCode = null, createdAtEpochMillis = now, @@ -43,4 +54,17 @@ class InfraredKeyFactory( ), ) } + + private fun canonicalizeId(value: String): String? = when { + ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase() + STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase() + else -> null + } + + private companion object { + val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$") + val STANDARD_UUID = Regex( + "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$", + ) + } } \ No newline at end of file diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt index 1e84c29..3092769 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt @@ -11,7 +11,11 @@ data class InfraredKeyRef( val keyId: InfraredKeyId, ) -enum class InfraredKeyStatus { LEARNING, READY, FAILED } +enum class InfraredKeyStatus { + LEARNING, + READY, + FAILED, +} data class SavedInfraredKey( val id: InfraredKeyId, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt index 9d7d38d..7b0e55c 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt @@ -6,15 +6,42 @@ import com.flagship.abox.manager.domain.ProviderProfileId import kotlinx.coroutines.flow.Flow interface InfraredKeyRepository { - fun observeKeys(profileId: ProviderProfileId, deviceName: DeviceName): Flow>> + fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> + suspend fun getKey(ref: InfraredKeyRef): DomainResult + suspend fun insert(key: SavedInfraredKey): DomainResult - suspend fun update(key: SavedInfraredKey): DomainResult - suspend fun delete(ref: InfraredKeyRef): DomainResult - suspend fun findByStatus(profileId: ProviderProfileId, status: InfraredKeyStatus): DomainResult> + + suspend fun transitionStatus( + ref: InfraredKeyRef, + expectedStatuses: Set, + targetStatus: InfraredKeyStatus, + lastErrorCode: String?, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun rename( + ref: InfraredKeyRef, + displayName: String, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult + + suspend fun failInterruptedLearning( + profileId: ProviderProfileId, + updatedAtEpochMillis: Long, + ): DomainResult } interface InfraredDeviceConfigurationRepository { fun observeDeviceName(profileId: ProviderProfileId): Flow> - suspend fun setDeviceName(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult + + suspend fun setDeviceName( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult } \ No newline at end of file diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt index acb8fad..2adbe1e 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt @@ -35,4 +35,34 @@ class InfraredKeyFactoryTest { assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure) } + + @Test + fun rejectsIdentifiersWithSurroundingWhitespace() { + assertTrue(factory.create(ProviderProfileId(" abox"), DeviceName("ir-1"), "Power") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1 "), "Power") is DomainResult.Failure) + } + + @Test + fun rejectsLossyOrOversizedIds() { + assertTrue(factoryWithId("ab-c").createValidKey() is DomainResult.Failure) + assertTrue(factoryWithId("a".repeat(33)).createValidKey() is DomainResult.Failure) + } + + @Test + fun canonicalizesStandardUuidId() { + val result = factoryWithId("550E8400-E29B-41D4-A716-446655440000").createValidKey() as DomainResult.Success + + assertEquals("app_ir_550e8400e29b41d4a716446655440000", result.value.sdkKey) + } + + private fun factoryWithId(value: String) = InfraredKeyFactory( + idGenerator = InfraredKeyIdGenerator { InfraredKeyId(value) }, + clock = EpochMillisClock { 1_724_435_200_000L }, + ) + + private fun InfraredKeyFactory.createValidKey() = create( + ProviderProfileId("abox"), + DeviceName("ir-1"), + "Power", + ) } \ No newline at end of file