diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt new file mode 100644 index 0000000..0a5bade --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt @@ -0,0 +1,68 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.Flow + +@JvmInline +value class ActivityEventId(val value: String) + +enum class ActivityEventKind { + SESSION_SIGNED_IN, + SESSION_SIGNED_OUT, + SOCKET_COMMAND, + SOCKET_CONFIRMATION, + SENSOR_REFRESH, + INFRARED_SEND, + INFRARED_LEARN, + SCENE_EXECUTION, + SCENE_ACTION, +} + +enum class ActivitySource { USER, AUTOMATION, SYSTEM } + +enum class ActivityOutcome { + SUCCEEDED, + FAILED, + CANCELLED, + AWAITING_CONFIRMATION, +} + +enum class ActivityTargetType { PROFILE, DEVICE, SCENE, SCENE_ACTION, REMOTE, INFRARED_KEY } + +data class ActivityTarget( + val type: ActivityTargetType, + val stableId: String, + val displayName: String, +) + +data class ActivityEvent( + val id: ActivityEventId, + val profileId: ProviderProfileId, + val occurredAtEpochMillis: Long, + val kind: ActivityEventKind, + val source: ActivitySource, + val outcome: ActivityOutcome, + val target: ActivityTarget, + val errorCode: String? = null, +) + +data class ActivityEventCursor( + val occurredAtEpochMillis: Long, + val id: ActivityEventId, +) : Comparable { + override fun compareTo(other: ActivityEventCursor): Int { + val timeComparison = occurredAtEpochMillis.compareTo(other.occurredAtEpochMillis) + return if (timeComparison != 0) timeComparison else id.value.compareTo(other.id.value) + } +} + +interface ActivityRepository { + fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> + + suspend fun loadBefore( + profileId: ProviderProfileId, + before: ActivityEventCursor, + limit: Int, + ): DomainResult> + + suspend fun append(event: ActivityEvent): DomainResult +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt new file mode 100644 index 0000000..cf3a713 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt @@ -0,0 +1,78 @@ +package com.flagship.abox.manager.domain.activity + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +import com.flagship.abox.manager.domain.ActivityEventId +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivityRepository +import com.flagship.abox.manager.domain.ActivitySource +import com.flagship.abox.manager.domain.ActivityTarget +import com.flagship.abox.manager.domain.ActivityTargetType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ActivityContractTest { + @Test + fun eventDoesNotContainCredentialsOrPayloadFields() { + val event = ActivityEvent( + id = ActivityEventId("event-1"), + profileId = ProviderProfileId("profile-1"), + occurredAtEpochMillis = 10L, + kind = ActivityEventKind.SOCKET_COMMAND, + source = ActivitySource.USER, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.DEVICE, "device-1", "客厅插座"), + errorCode = "20504", + ) + + val fields = ActivityEvent::class.java.declaredFields.map { it.name } + assertFalse(fields.any { it.contains("password", ignoreCase = true) }) + assertFalse(fields.any { it.contains("token", ignoreCase = true) }) + assertFalse(fields.any { it.contains("payload", ignoreCase = true) }) + assertTrue(event.errorCode == "20504") + } + + @Test + fun cursorOrdersEqualTimestampsById() { + val first = ActivityEventCursor(10L, ActivityEventId("a")) + val second = ActivityEventCursor(10L, ActivityEventId("b")) + + assertTrue(first < second) + } + + @Test + fun recordingFailureDoesNotAlterOriginalDomainResult() = runTest { + val repository = object : ActivityRepository { + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = + MutableStateFlow(DomainResult.Success(emptyList())) + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = + DomainResult.Success(emptyList()) + override suspend fun append(event: ActivityEvent) = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.Unknown()) + } + val original: DomainResult = DomainResult.Success("command-complete") + + val recorded = repository.append(testEvent()) + + assertTrue(recorded is DomainResult.Failure) + assertTrue(original is DomainResult.Success) + assertTrue((original as DomainResult.Success).value == "command-complete") + } + + private fun testEvent() = ActivityEvent( + id = ActivityEventId("event-1"), + profileId = ProviderProfileId("profile-1"), + occurredAtEpochMillis = 10L, + kind = ActivityEventKind.SESSION_SIGNED_IN, + source = ActivitySource.SYSTEM, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.PROFILE, "profile-1", "账户"), + ) +}