diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForRecordUseCase.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForRecordUseCase.kt index 2942f9a25d..bea30b5b03 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForRecordUseCase.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForRecordUseCase.kt @@ -8,6 +8,8 @@ import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepositor import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecord import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecordQuery import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEvents +import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEventsV1 +import com.simprints.infra.events.event.cosync.v1.toCoSync import com.simprints.infra.events.event.domain.models.EnrolmentRecordCreationEvent import com.simprints.infra.logging.Simber import com.simprints.infra.serialization.SimJson @@ -40,7 +42,9 @@ internal class GetEnrolmentCreationEventForRecordUseCase @Inject constructor( ) return null } - return SimJson.encodeToString(CoSyncEnrolmentRecordEvents(listOf(recordCreationEvent))) + return SimJson.encodeToString( + CoSyncEnrolmentRecordEventsV1(events = listOf(recordCreationEvent.toCoSync())), + ) } private fun EnrolmentRecord.fromSubjectToEnrolmentCreationEvent() = EnrolmentRecordCreationEvent( diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareCandidateRecordDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareCandidateRecordDataSource.kt index 0b327b9bac..aab026c45b 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareCandidateRecordDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareCandidateRecordDataSource.kt @@ -203,7 +203,7 @@ internal class CommCareCandidateRecordDataSource @Inject constructor( Simber.d(subjectActions) val coSyncEnrolmentRecordEvents = parseRecordEvents(subjectActions) - coSyncEnrolmentRecordEvents?.events?.filterIsInstance()?.filter { event -> + coSyncEnrolmentRecordEvents?.toDomainEvents()?.filterIsInstance()?.filter { event -> // [MS-852] Plain strings from CommCare might be tokenized or untokenized. The only way to properly compare them // is by trying to decrypt the values to check if already tokenized, and then compare the values isSubjectIdNullOrMatching(query, event) && diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt index 18edea5b1e..94f3f0747d 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt @@ -193,7 +193,7 @@ internal class CommCareEventDataSource @Inject constructor( } coSyncEnrolmentRecordEvents - .events + .toDomainEvents() .filterIsInstance() .forEach { event -> pendingSyncedCases.add(case.copy(simprintsId = event.payload.subjectId)) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEvents.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEvents.kt index 30f9ba851a..56d9a9195d 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEvents.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEvents.kt @@ -2,10 +2,58 @@ package com.simprints.infra.events.event.cosync import androidx.annotation.Keep import com.simprints.infra.events.event.domain.models.EnrolmentRecordEvent +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +/** Extracts the major version from a schema version string (e.g. "1.2" → "1"). */ +private fun String.majorVersion(): String = substringBefore(".") + +/** + * Sealed interface for versioned CoSync enrolment record events. + * + * Uses [CoSyncEnrolmentRecordEventsSerializer] to inspect the `schemaVersion` + * field in JSON and route to the correct version-specific deserializer. + * Missing `schemaVersion` defaults to V1 for backward compatibility. + * + * Versioning convention: + * - Major version changes (e.g. "1.x" → "2.x") require a new sealed subclass. + * - Minor version changes (e.g. "1.0" → "1.1") are non-breaking (new optional/nullable + * fields) and are handled by the same subclass via `ignoreUnknownKeys` and defaults. + */ @Keep -@Serializable -data class CoSyncEnrolmentRecordEvents( - val events: List, -) +@Serializable(with = CoSyncEnrolmentRecordEventsSerializer::class) +sealed interface CoSyncEnrolmentRecordEvents { + val schemaVersion: String + + /** Converts version-specific models to internal domain events. */ + fun toDomainEvents(): List +} + +/** + * Polymorphic serializer that inspects the `schemaVersion` JSON field + * to select the correct [CoSyncEnrolmentRecordEvents] subclass. + * + * Matches on major version only. See [CoSyncEnrolmentRecordEvents] for versioning convention. + * Defaults to [CoSyncEnrolmentRecordEventsV1] when `schemaVersion` is missing. + */ +internal object CoSyncEnrolmentRecordEventsSerializer : + JsonContentPolymorphicSerializer(CoSyncEnrolmentRecordEvents::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + val majorVersion = element.jsonObject["schemaVersion"] + ?.jsonPrimitive + ?.contentOrNull + ?.majorVersion() + + return when (majorVersion) { + null, CoSyncEnrolmentRecordEventsV1.SCHEMA_VERSION.majorVersion() -> CoSyncEnrolmentRecordEventsV1.serializer() + else -> throw IllegalArgumentException( + "Unknown CoSync schemaVersion: ${element.jsonObject["schemaVersion"]}", + ) + } + } +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEventsV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEventsV1.kt new file mode 100644 index 0000000000..4245ef28bc --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEventsV1.kt @@ -0,0 +1,28 @@ +package com.simprints.infra.events.event.cosync + +import androidx.annotation.Keep +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordEvent +import com.simprints.infra.events.event.cosync.v1.toEventDomain +import com.simprints.infra.events.event.domain.models.EnrolmentRecordEvent +import kotlinx.serialization.Serializable + +/** + * V1 schema for CoSync enrolment record events. + * + * Compatibility: + * - Old JSON without `schemaVersion` is deserialized as V1 (backward compatible). + * - Serialized JSON includes `schemaVersion = "1.0"` (forward compatible). + */ +@Keep +@Serializable +data class CoSyncEnrolmentRecordEventsV1( + override val schemaVersion: String = SCHEMA_VERSION, + val events: List, +) : CoSyncEnrolmentRecordEvents { + + override fun toDomainEvents(): List = events.map { it.toEventDomain() } + + companion object { + const val SCHEMA_VERSION = "1.0" + } +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncBiometricReference.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncBiometricReference.kt new file mode 100644 index 0000000000..74e2ef7a02 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncBiometricReference.kt @@ -0,0 +1,77 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.infra.events.event.domain.models.BiometricReference +import com.simprints.infra.events.event.domain.models.FaceReference +import com.simprints.infra.events.event.domain.models.FingerprintReference +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * V1 external schema for biometric references (polymorphic base type). + * Stable external contract decoupled from internal [BiometricReference]. + */ +@Keep +@Serializable +sealed class CoSyncBiometricReference { + abstract val id: String + abstract val format: String +} + +@Keep +@Serializable +@SerialName("FACE_REFERENCE") +data class CoSyncFaceReference( + override val id: String, + val templates: List, + override val format: String, + val metadata: Map? = null, +) : CoSyncBiometricReference() + +@Keep +@Serializable +@SerialName("FINGERPRINT_REFERENCE") +data class CoSyncFingerprintReference( + override val id: String, + val templates: List, + override val format: String, + val metadata: Map? = null, +) : CoSyncBiometricReference() + +fun BiometricReference.toCoSync(): CoSyncBiometricReference = when (this) { + is FaceReference -> toCoSync() + is FingerprintReference -> toCoSync() +} + +fun CoSyncBiometricReference.toEventDomain(): BiometricReference = when (this) { + is CoSyncFaceReference -> toEventDomain() + is CoSyncFingerprintReference -> toEventDomain() +} + +fun FaceReference.toCoSync() = CoSyncFaceReference( + id = id, + templates = templates.map { it.toCoSync() }, + format = format, + metadata = metadata, +) + +fun CoSyncFaceReference.toEventDomain() = FaceReference( + id = id, + templates = templates.map { it.toEventDomain() }, + format = format, + metadata = metadata, +) + +fun FingerprintReference.toCoSync() = CoSyncFingerprintReference( + id = id, + templates = templates.map { it.toCoSync() }, + format = format, + metadata = metadata, +) + +fun CoSyncFingerprintReference.toEventDomain() = FingerprintReference( + id = id, + templates = templates.map { it.toEventDomain() }, + format = format, + metadata = metadata, +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEvent.kt new file mode 100644 index 0000000000..1d70bedb9d --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEvent.kt @@ -0,0 +1,72 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.infra.events.event.domain.models.EnrolmentRecordCreationEvent +import com.simprints.infra.events.event.domain.models.EnrolmentRecordEvent +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * V1 external schema for enrolment record events (polymorphic base type). + * Stable external contract decoupled from internal [EnrolmentRecordEvent]. + */ +@Keep +@Serializable +sealed class CoSyncEnrolmentRecordEvent { + abstract val id: String +} + +/** + * V1 external schema for enrolment record creation event. + */ +@Keep +@Serializable +@SerialName("EnrolmentRecordCreation") +data class CoSyncEnrolmentRecordCreationEvent( + override val id: String, + val payload: CoSyncEnrolmentRecordCreationPayload, +) : CoSyncEnrolmentRecordEvent() + +@Keep +@Serializable +data class CoSyncEnrolmentRecordCreationPayload( + val subjectId: String, + val projectId: String, + val moduleId: CoSyncTokenizableString, + val attendantId: CoSyncTokenizableString, + val biometricReferences: List = emptyList(), + val externalCredentials: List = emptyList(), +) + +fun EnrolmentRecordEvent.toCoSync(): CoSyncEnrolmentRecordEvent = when (this) { + is EnrolmentRecordCreationEvent -> toCoSync() + else -> throw IllegalArgumentException("Unsupported event type for V1: ${this::class.simpleName}") +} + +fun CoSyncEnrolmentRecordEvent.toEventDomain(): EnrolmentRecordEvent = when (this) { + is CoSyncEnrolmentRecordCreationEvent -> toEventDomain() +} + +fun EnrolmentRecordCreationEvent.toCoSync() = CoSyncEnrolmentRecordCreationEvent( + id = id, + payload = CoSyncEnrolmentRecordCreationPayload( + subjectId = payload.subjectId, + projectId = payload.projectId, + moduleId = payload.moduleId.toCoSync(), + attendantId = payload.attendantId.toCoSync(), + biometricReferences = payload.biometricReferences.map { it.toCoSync() }, + externalCredentials = payload.externalCredentials.map { it.toCoSync() }, + ), +) + +fun CoSyncEnrolmentRecordCreationEvent.toEventDomain() = EnrolmentRecordCreationEvent( + id = id, + payload = EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( + subjectId = payload.subjectId, + projectId = payload.projectId, + moduleId = payload.moduleId.toDomain(), + attendantId = payload.attendantId.toDomain(), + biometricReferences = payload.biometricReferences.map { it.toEventDomain() }, + externalCredentials = payload.externalCredentials.map { it.toDomain() }, + ), +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncExternalCredential.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncExternalCredential.kt new file mode 100644 index 0000000000..375a2ac747 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncExternalCredential.kt @@ -0,0 +1,36 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.core.domain.externalcredential.ExternalCredential +import com.simprints.core.domain.tokenization.TokenizableString +import kotlinx.serialization.Serializable + +/** + * V1 external schema for external credentials. + * Stable external contract decoupled from internal [ExternalCredential]. + */ +@Keep +@Serializable +data class CoSyncExternalCredential( + val id: String, + val value: CoSyncTokenizableString, + val subjectId: String, + val type: CoSyncExternalCredentialType, +) + +fun ExternalCredential.toCoSync() = CoSyncExternalCredential( + id = id, + value = value.toCoSync(), + subjectId = subjectId, + type = type.toCoSync(), +) + +fun CoSyncExternalCredential.toDomain() = ExternalCredential( + id = id, + value = when (val domainValue = value.toDomain()) { + is TokenizableString.Tokenized -> domainValue + is TokenizableString.Raw -> error("ExternalCredential value must be Tokenized, got Raw for id=$id") + }, + subjectId = subjectId, + type = type.toDomain(), +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncExternalCredentialType.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncExternalCredentialType.kt new file mode 100644 index 0000000000..1bfa503f14 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncExternalCredentialType.kt @@ -0,0 +1,31 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import kotlinx.serialization.Serializable + +/** + * V1 external schema for external credential types. + * Stable external contract decoupled from internal [ExternalCredentialType]. + */ +@Keep +@Serializable +@ExcludedFromGeneratedTestCoverageReports("Enum") +enum class CoSyncExternalCredentialType { + NHISCard, + GhanaIdCard, + QRCode, +} + +fun ExternalCredentialType.toCoSync(): CoSyncExternalCredentialType = when (this) { + ExternalCredentialType.NHISCard -> CoSyncExternalCredentialType.NHISCard + ExternalCredentialType.GhanaIdCard -> CoSyncExternalCredentialType.GhanaIdCard + ExternalCredentialType.QRCode -> CoSyncExternalCredentialType.QRCode +} + +fun CoSyncExternalCredentialType.toDomain(): ExternalCredentialType = when (this) { + CoSyncExternalCredentialType.NHISCard -> ExternalCredentialType.NHISCard + CoSyncExternalCredentialType.GhanaIdCard -> ExternalCredentialType.GhanaIdCard + CoSyncExternalCredentialType.QRCode -> ExternalCredentialType.QRCode +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTemplate.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTemplate.kt new file mode 100644 index 0000000000..0723aea4d9 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTemplate.kt @@ -0,0 +1,32 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.infra.events.event.domain.models.FaceTemplate +import com.simprints.infra.events.event.domain.models.FingerprintTemplate +import kotlinx.serialization.Serializable + +/** V1 external schema for face template. */ +@Keep +@Serializable +data class CoSyncFaceTemplate(val template: String) + +/** V1 external schema for fingerprint template with finger identifier. */ +@Keep +@Serializable +data class CoSyncFingerprintTemplate( + val template: String, + val finger: CoSyncTemplateIdentifier, +) + +fun FaceTemplate.toCoSync() = CoSyncFaceTemplate(template = template) +fun CoSyncFaceTemplate.toEventDomain() = FaceTemplate(template = template) + +fun FingerprintTemplate.toCoSync() = CoSyncFingerprintTemplate( + template = template, + finger = finger.toCoSync(), +) + +fun CoSyncFingerprintTemplate.toEventDomain() = FingerprintTemplate( + template = template, + finger = finger.toDomain(), +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTemplateIdentifier.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTemplateIdentifier.kt new file mode 100644 index 0000000000..c5efc6123c --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTemplateIdentifier.kt @@ -0,0 +1,55 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.common.TemplateIdentifier +import kotlinx.serialization.Serializable + +/** + * V1 external schema for template identifiers. + * Stable external contract decoupled from internal [TemplateIdentifier]. + */ +@Keep +@Serializable +@ExcludedFromGeneratedTestCoverageReports("Enum") +enum class CoSyncTemplateIdentifier { + NONE, + RIGHT_5TH_FINGER, + RIGHT_4TH_FINGER, + RIGHT_3RD_FINGER, + RIGHT_INDEX_FINGER, + RIGHT_THUMB, + LEFT_THUMB, + LEFT_INDEX_FINGER, + LEFT_3RD_FINGER, + LEFT_4TH_FINGER, + LEFT_5TH_FINGER, +} + +fun TemplateIdentifier.toCoSync(): CoSyncTemplateIdentifier = when (this) { + TemplateIdentifier.NONE -> CoSyncTemplateIdentifier.NONE + TemplateIdentifier.RIGHT_5TH_FINGER -> CoSyncTemplateIdentifier.RIGHT_5TH_FINGER + TemplateIdentifier.RIGHT_4TH_FINGER -> CoSyncTemplateIdentifier.RIGHT_4TH_FINGER + TemplateIdentifier.RIGHT_3RD_FINGER -> CoSyncTemplateIdentifier.RIGHT_3RD_FINGER + TemplateIdentifier.RIGHT_INDEX_FINGER -> CoSyncTemplateIdentifier.RIGHT_INDEX_FINGER + TemplateIdentifier.RIGHT_THUMB -> CoSyncTemplateIdentifier.RIGHT_THUMB + TemplateIdentifier.LEFT_THUMB -> CoSyncTemplateIdentifier.LEFT_THUMB + TemplateIdentifier.LEFT_INDEX_FINGER -> CoSyncTemplateIdentifier.LEFT_INDEX_FINGER + TemplateIdentifier.LEFT_3RD_FINGER -> CoSyncTemplateIdentifier.LEFT_3RD_FINGER + TemplateIdentifier.LEFT_4TH_FINGER -> CoSyncTemplateIdentifier.LEFT_4TH_FINGER + TemplateIdentifier.LEFT_5TH_FINGER -> CoSyncTemplateIdentifier.LEFT_5TH_FINGER +} + +fun CoSyncTemplateIdentifier.toDomain(): TemplateIdentifier = when (this) { + CoSyncTemplateIdentifier.NONE -> TemplateIdentifier.NONE + CoSyncTemplateIdentifier.RIGHT_5TH_FINGER -> TemplateIdentifier.RIGHT_5TH_FINGER + CoSyncTemplateIdentifier.RIGHT_4TH_FINGER -> TemplateIdentifier.RIGHT_4TH_FINGER + CoSyncTemplateIdentifier.RIGHT_3RD_FINGER -> TemplateIdentifier.RIGHT_3RD_FINGER + CoSyncTemplateIdentifier.RIGHT_INDEX_FINGER -> TemplateIdentifier.RIGHT_INDEX_FINGER + CoSyncTemplateIdentifier.RIGHT_THUMB -> TemplateIdentifier.RIGHT_THUMB + CoSyncTemplateIdentifier.LEFT_THUMB -> TemplateIdentifier.LEFT_THUMB + CoSyncTemplateIdentifier.LEFT_INDEX_FINGER -> TemplateIdentifier.LEFT_INDEX_FINGER + CoSyncTemplateIdentifier.LEFT_3RD_FINGER -> TemplateIdentifier.LEFT_3RD_FINGER + CoSyncTemplateIdentifier.LEFT_4TH_FINGER -> TemplateIdentifier.LEFT_4TH_FINGER + CoSyncTemplateIdentifier.LEFT_5TH_FINGER -> TemplateIdentifier.LEFT_5TH_FINGER +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTokenizableString.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTokenizableString.kt new file mode 100644 index 0000000000..ae59828968 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncTokenizableString.kt @@ -0,0 +1,93 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.core.domain.tokenization.TokenizableString +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** + * V1 external schema for tokenizable strings. + * + * Stable external contract decoupled from internal [TokenizableString]. + * Handles backward compatibility with plain strings and objects with/without className. + */ +@Keep +@Serializable(with = CoSyncTokenizableStringSerializer::class) +sealed class CoSyncTokenizableString { + abstract val value: String + + data class Tokenized(override val value: String) : CoSyncTokenizableString() + data class Raw(override val value: String) : CoSyncTokenizableString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is CoSyncTokenizableString && other.value == value + } + + override fun hashCode(): Int = value.hashCode() + override fun toString(): String = value +} + +fun TokenizableString.toCoSync(): CoSyncTokenizableString = when (this) { + is TokenizableString.Tokenized -> CoSyncTokenizableString.Tokenized(value) + is TokenizableString.Raw -> CoSyncTokenizableString.Raw(value) +} + +fun CoSyncTokenizableString.toDomain(): TokenizableString = when (this) { + is CoSyncTokenizableString.Tokenized -> TokenizableString.Tokenized(value) + is CoSyncTokenizableString.Raw -> TokenizableString.Raw(value) +} + +/** + * Serializes [CoSyncTokenizableString] as `{ "className": "...", "value": "..." }`. + * Deserializes plain strings, objects without className, and objects with className. + */ +internal object CoSyncTokenizableStringSerializer : KSerializer { + private const val TOKENIZED = "TokenizableString.Tokenized" + private const val RAW = "TokenizableString.Raw" + private const val FIELD_CLASS_NAME = "className" + private const val FIELD_VALUE = "value" + + override val descriptor = buildClassSerialDescriptor("CoSyncTokenizableString") { + element(FIELD_CLASS_NAME, PrimitiveSerialDescriptor(FIELD_CLASS_NAME, PrimitiveKind.STRING)) + element(FIELD_VALUE, PrimitiveSerialDescriptor(FIELD_VALUE, PrimitiveKind.STRING)) + } + + override fun serialize(encoder: Encoder, value: CoSyncTokenizableString) { + require(encoder is JsonEncoder) + val className = if (value is CoSyncTokenizableString.Tokenized) TOKENIZED else RAW + encoder.encodeJsonElement( + buildJsonObject { + put(FIELD_CLASS_NAME, className) + put(FIELD_VALUE, value.value) + }, + ) + } + + override fun deserialize(decoder: Decoder): CoSyncTokenizableString { + val jsonDecoder = decoder as? JsonDecoder ?: error("Only JSON is supported") + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonPrimitive -> CoSyncTokenizableString.Raw(element.content) + is JsonObject -> { + val className = element[FIELD_CLASS_NAME]?.jsonPrimitive?.content.orEmpty() + val value = element[FIELD_VALUE]?.jsonPrimitive?.content + ?: error("Missing 'value' field in CoSyncTokenizableString") + if (className == TOKENIZED) CoSyncTokenizableString.Tokenized(value) + else CoSyncTokenizableString.Raw(value) + } + else -> error("Unexpected JSON element for CoSyncTokenizableString") + } + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializerTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializerTest.kt deleted file mode 100644 index e622e8f861..0000000000 --- a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializerTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.simprints.infra.events.event.cosync - -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.infra.events.event.domain.models.BiometricReference -import com.simprints.infra.events.event.domain.models.EnrolmentRecordCreationEvent -import com.simprints.infra.serialization.SimJson -import org.junit.Test -import kotlin.test.assertEquals - -class CoSyncEnrolmentRecordCreationEventDeserializerTest { - @Test - fun `deserialize handles old format with plain strings`() { - // Arrange - val jsonString = JSON_TEMPLATE.format(PLAIN_MODULE, PLAIN_ATTENDANT) - - // Act - // We explicitly use the custom serializer we created in the previous step - val result = SimJson.decodeFromString(jsonString) - - // Assert - assertEquals(EVENT_ID, result.id) - assertEquals(SUBJECT_ID, result.payload.subjectId) - assertEquals(PROJECT_ID, result.payload.projectId) - - // Expect Raw strings because the JSON input was simple strings - assertEquals(TokenizableString.Raw(MODULE_ID), result.payload.moduleId) - assertEquals(TokenizableString.Raw(ATTENDANT_ID), result.payload.attendantId) - assertEquals(emptyList(), result.payload.biometricReferences) - } - - @Test - fun `deserialize handles new format with TokenizableString`() { - // Arrange - // This input mimics the polymorphic object structure - val jsonString = JSON_TEMPLATE.format(TOKENIZED_MODULE, RAW_ATTENDANT) - - // Act - val result = SimJson.decodeFromString(jsonString) - - // Assert - assertEquals(EVENT_ID, result.id) - assertEquals(SUBJECT_ID, result.payload.subjectId) - assertEquals(PROJECT_ID, result.payload.projectId) - - // These assertions assume that TokenizableString deserialization logic - // (inside the try/catch of the custom serializer) correctly parses these objects. - // If the parsing fails (e.g. discriminator mismatch), the serializer falls back to Raw(jsonString). - // ideally, this returns the typed objects: - assertEquals(TokenizableString.Tokenized(ENCRYPTED_MODULE), result.payload.moduleId) - assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) - assertEquals(emptyList(), result.payload.biometricReferences) - } - - @Test - fun `deserialize handles new format with TokenizableString but without explicit class`() { - // Arrange - val jsonString = JSON_TEMPLATE.format(TOKENIZED_MODULE_NO_CLASS, RAW_ATTENDANT_NO_CLASS) - - // Act - val result = SimJson.decodeFromString(jsonString) - - // Assert - assertEquals(EVENT_ID, result.id) - assertEquals(SUBJECT_ID, result.payload.subjectId) - assertEquals(PROJECT_ID, result.payload.projectId) - - // In the previous Serializer implementation, if the JSON is an object but fails - // standard deserialization (e.g. missing class discriminator), - // the catch block returns TokenizableString.Raw(element.toString()). - // Therefore, we verify that the fallback logic worked. - // Note: The original test mocked this to return just the value "encrypted-module-1". - // The real implementation likely returns the full JSON object string unless - // TokenizableString has a custom serializer that handles missing discriminators. - - // Assuming the fallback logic wraps the JSON string: - assert(result.payload.moduleId is TokenizableString.Raw) - assert(result.payload.attendantId is TokenizableString.Raw) - assertEquals(TokenizableString.Raw(ENCRYPTED_MODULE), result.payload.moduleId) - assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) - - assertEquals(emptyList(), result.payload.biometricReferences) - } - - companion object { - const val EVENT_ID = "event-id" - const val SUBJECT_ID = "subject-1" - const val PROJECT_ID = "project-1" - const val MODULE_ID = "module-1" - const val ATTENDANT_ID = "attendant-1" - const val ENCRYPTED_MODULE = "encrypted-module-1" - const val UNENCRYPTED_ATTENDANT = "unencrypted-attendant-1" - - const val JSON_TEMPLATE = """ - { - "id": "$EVENT_ID", - "payload": { - "subjectId": "$SUBJECT_ID", - "projectId": "$PROJECT_ID", - %s, - %s, - "biometricReferences": [] - } - }""" - - const val PLAIN_MODULE = """ - "moduleId": "$MODULE_ID"""" - const val PLAIN_ATTENDANT = """ - "attendantId": "$ATTENDANT_ID"""" - - const val TOKENIZED_MODULE = """ - "moduleId": { - "className": "TokenizableString.Tokenized", - "value": "$ENCRYPTED_MODULE" - }""" - const val RAW_ATTENDANT = """ - "attendantId": { - "className": "TokenizableString.Raw", - "value": "$UNENCRYPTED_ATTENDANT" - }""" - - const val TOKENIZED_MODULE_NO_CLASS = """ - "moduleId": { - "value": "$ENCRYPTED_MODULE" - }""" - const val RAW_ATTENDANT_NO_CLASS = """ - "attendantId": { - "value": "$UNENCRYPTED_ATTENDANT" - }""" - } -} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEventsVersioningTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEventsVersioningTest.kt new file mode 100644 index 0000000000..ab43d71c1e --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEventsVersioningTest.kt @@ -0,0 +1,529 @@ +package com.simprints.infra.events.event.cosync + +import com.simprints.core.domain.common.TemplateIdentifier +import com.simprints.core.domain.externalcredential.ExternalCredential +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordCreationEvent +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordCreationPayload +import com.simprints.infra.events.event.cosync.v1.CoSyncExternalCredential +import com.simprints.infra.events.event.cosync.v1.CoSyncExternalCredentialType +import com.simprints.infra.events.event.cosync.v1.CoSyncFaceReference +import com.simprints.infra.events.event.cosync.v1.CoSyncFaceTemplate +import com.simprints.infra.events.event.cosync.v1.CoSyncFingerprintReference +import com.simprints.infra.events.event.cosync.v1.CoSyncFingerprintTemplate +import com.simprints.infra.events.event.cosync.v1.CoSyncTemplateIdentifier +import com.simprints.infra.events.event.cosync.v1.CoSyncTokenizableString +import com.simprints.infra.events.event.cosync.v1.toCoSync +import com.simprints.infra.events.event.cosync.v1.toDomain +import com.simprints.infra.events.event.domain.models.EnrolmentRecordCreationEvent +import com.simprints.infra.events.event.domain.models.EnrolmentRecordDeletionEvent +import com.simprints.infra.events.event.domain.models.FaceReference +import com.simprints.infra.events.event.domain.models.FaceTemplate +import com.simprints.infra.events.event.domain.models.FingerprintReference +import com.simprints.infra.events.event.domain.models.FingerprintTemplate +import com.simprints.infra.serialization.SimJson +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CoSyncEnrolmentRecordEventsVersioningTest { + // region Schema version routing + + @Test + fun `deserialize old JSON without schemaVersion defaults to V1`() { + val json = buildCoSyncJson(schemaVersion = null) + + val result = SimJson.decodeFromString(json) + + assertIs(result) + val domainEvents = result.toDomainEvents() + assertEquals(1, domainEvents.size) + assertIs(domainEvents.first()) + } + + @Test + fun `deserialize V1 JSON with schemaVersion parses correctly`() { + val json = buildCoSyncJson() + + val result = SimJson.decodeFromString(json) + + assertIs(result) + assertEquals(CoSyncEnrolmentRecordEventsV1.SCHEMA_VERSION, result.schemaVersion) + assertEquals(1, result.events.size) + } + + @Test + fun `minor version increment deserializes as same major version`() { + val json = buildCoSyncJson(schemaVersion = "1.3") + + val result = SimJson.decodeFromString(json) + + assertIs(result) + assertEquals("1.3", result.schemaVersion) + } + + @Test + fun `unknown schemaVersion throws exception`() { + val json = """{"schemaVersion": "99.0", "events": []}""" + + val exception = assertFailsWith { + SimJson.decodeFromString(json) + } + assertTrue(exception.message!!.contains("99.0")) + } + + // endregion + + // region Serialization + + @Test + fun `serialization includes schemaVersion field`() { + val wrapper = buildSimpleV1Event() + + val json = SimJson.encodeToString(wrapper) + + assertTrue(json.contains("\"schemaVersion\":\"1.0\"")) + } + + // endregion + + // region Roundtrip + + @Test + fun `roundtrip preserves basic event data`() { + val original = CoSyncEnrolmentRecordEventsV1( + events = listOf( + CoSyncEnrolmentRecordCreationEvent( + id = "event-1", + payload = CoSyncEnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = CoSyncTokenizableString.Raw("module-1"), + attendantId = CoSyncTokenizableString.Tokenized("encrypted-attendant"), + ), + ), + ), + ) + + val event = encodeAndDecodeDomainEvent(original) + + assertEquals("event-1", event.id) + assertEquals("subject-1", event.payload.subjectId) + assertEquals("project-1", event.payload.projectId) + assertEquals(TokenizableString.Raw("module-1"), event.payload.moduleId) + assertEquals(TokenizableString.Tokenized("encrypted-attendant"), event.payload.attendantId) + } + + @Test + fun `roundtrip with face biometric reference preserves data`() { + val original = CoSyncEnrolmentRecordEventsV1( + events = listOf( + CoSyncEnrolmentRecordCreationEvent( + id = "event-1", + payload = CoSyncEnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = CoSyncTokenizableString.Raw("module-1"), + attendantId = CoSyncTokenizableString.Raw("attendant-1"), + biometricReferences = listOf( + CoSyncFaceReference( + id = "ref-1", + templates = listOf(CoSyncFaceTemplate(template = "dGVtcGxhdGU=")), + format = "NEC_5", + ), + ), + ), + ), + ), + ) + + val event = encodeAndDecodeDomainEvent(original) + val ref = assertIs(event.payload.biometricReferences.single()) + assertEquals("ref-1", ref.id) + assertEquals("NEC_5", ref.format) + assertEquals("dGVtcGxhdGU=", ref.templates.first().template) + } + + @Test + fun `roundtrip with fingerprint biometric reference preserves data`() { + val original = CoSyncEnrolmentRecordEventsV1( + events = listOf( + CoSyncEnrolmentRecordCreationEvent( + id = "event-1", + payload = CoSyncEnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = CoSyncTokenizableString.Raw("module-1"), + attendantId = CoSyncTokenizableString.Raw("attendant-1"), + biometricReferences = listOf( + CoSyncFingerprintReference( + id = "ref-2", + templates = listOf( + CoSyncFingerprintTemplate( + template = "ZmluZ2VycHJpbnQ=", + finger = CoSyncTemplateIdentifier.LEFT_INDEX_FINGER, + ), + ), + format = "ISO_19794_2", + ), + ), + ), + ), + ), + ) + + val event = encodeAndDecodeDomainEvent(original) + val ref = assertIs(event.payload.biometricReferences.single()) + assertEquals("ref-2", ref.id) + assertEquals("ISO_19794_2", ref.format) + assertEquals("ZmluZ2VycHJpbnQ=", ref.templates.first().template) + assertEquals(TemplateIdentifier.LEFT_INDEX_FINGER, ref.templates.first().finger) + } + + @Test + fun `roundtrip with external credentials preserves data`() { + val original = CoSyncEnrolmentRecordEventsV1( + events = listOf( + CoSyncEnrolmentRecordCreationEvent( + id = "event-1", + payload = CoSyncEnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = CoSyncTokenizableString.Raw("module-1"), + attendantId = CoSyncTokenizableString.Raw("attendant-1"), + externalCredentials = listOf( + CoSyncExternalCredential( + id = "cred-1", + value = CoSyncTokenizableString.Tokenized("encrypted-value"), + subjectId = "subject-1", + type = CoSyncExternalCredentialType.NHISCard, + ), + ), + ), + ), + ), + ) + + val event = encodeAndDecodeDomainEvent(original) + val cred = event.payload.externalCredentials.single() + assertEquals("cred-1", cred.id) + assertEquals(TokenizableString.Tokenized("encrypted-value"), cred.value) + assertEquals("subject-1", cred.subjectId) + } + + // endregion + + // region JSON deserialization - nested types + + @Test + fun `deserialize JSON with face biometric reference`() { + val json = buildCoSyncJson( + biometricReferences = """[{ + "type": "FACE_REFERENCE", + "id": "ref-1", + "templates": [{"template": "dGVtcGxhdGU="}], + "format": "NEC_5" + }]""", + ) + + val event = decodeDomainEvent(json) + val ref = assertIs(event.payload.biometricReferences.single()) + assertEquals("dGVtcGxhdGU=", ref.templates.first().template) + } + + @Test + fun `deserialize JSON with fingerprint biometric reference`() { + val json = buildCoSyncJson( + biometricReferences = """[{ + "type": "FINGERPRINT_REFERENCE", + "id": "ref-2", + "templates": [{"template": "ZmluZ2VycHJpbnQ=", "finger": "LEFT_INDEX_FINGER"}], + "format": "ISO_19794_2" + }]""", + ) + + val event = decodeDomainEvent(json) + val ref = assertIs(event.payload.biometricReferences.single()) + assertEquals("ZmluZ2VycHJpbnQ=", ref.templates.first().template) + } + + @Test + fun `deserialize JSON with external credentials`() { + val json = buildCoSyncJson( + externalCredentials = """[{ + "id": "cred-1", + "value": {"className": "TokenizableString.Tokenized", "value": "encrypted-value"}, + "subjectId": "subject-1", + "type": "NHISCard" + }]""", + ) + + val event = decodeDomainEvent(json) + val cred = event.payload.externalCredentials.single() + assertEquals("cred-1", cred.id) + assertEquals(TokenizableString.Tokenized("encrypted-value"), cred.value) + } + + // endregion + + // region TokenizableString backward compatibility + + @Test + fun `deserialize event with plain string TokenizableString`() { + val json = buildCoSyncJson( + moduleId = "\"module-1\"", + attendantId = "\"attendant-1\"", + ) + + val event = decodeDomainEvent(json) + assertEquals(TokenizableString.Raw("module-1"), event.payload.moduleId) + assertEquals(TokenizableString.Raw("attendant-1"), event.payload.attendantId) + } + + @Test + fun `deserialize event with object TokenizableString with className`() { + val json = buildCoSyncJson( + moduleId = """{"className": "TokenizableString.Tokenized", "value": "encrypted-module"}""", + attendantId = """{"className": "TokenizableString.Raw", "value": "raw-attendant"}""", + ) + + val event = decodeDomainEvent(json) + assertEquals(TokenizableString.Tokenized("encrypted-module"), event.payload.moduleId) + assertEquals(TokenizableString.Raw("raw-attendant"), event.payload.attendantId) + } + + @Test + fun `deserialize event with object TokenizableString without className`() { + val json = buildCoSyncJson( + moduleId = """{"value": "encrypted-module"}""", + attendantId = """{"value": "raw-attendant"}""", + ) + + val event = decodeDomainEvent(json) + // Missing className defaults to Raw + assertEquals(TokenizableString.Raw("encrypted-module"), event.payload.moduleId) + assertEquals(TokenizableString.Raw("raw-attendant"), event.payload.attendantId) + } + + // endregion + + // region Domain to CoSync converter roundtrip + + @Test + fun `domain event converts to CoSync and back preserving all fields`() { + val domain = EnrolmentRecordCreationEvent( + subjectId = "subject-1", + projectId = "project-1", + moduleId = TokenizableString.Tokenized("encrypted-module"), + attendantId = TokenizableString.Raw("attendant-1"), + biometricReferences = listOf( + FaceReference( + id = "face-ref-1", + templates = listOf(FaceTemplate(template = "dGVtcGxhdGU=")), + format = "NEC_5", + metadata = mapOf("key" to "value"), + ), + FingerprintReference( + id = "fp-ref-1", + templates = listOf( + FingerprintTemplate( + template = "ZmluZ2VycHJpbnQ=", + finger = TemplateIdentifier.LEFT_INDEX_FINGER, + ), + ), + format = "ISO_19794_2", + ), + ), + externalCredentials = listOf( + ExternalCredential( + id = "cred-1", + value = TokenizableString.Tokenized("encrypted-value"), + subjectId = "subject-1", + type = ExternalCredentialType.NHISCard, + ), + ), + ) + + val coSync = domain.toCoSync() + val wrapper = CoSyncEnrolmentRecordEventsV1(events = listOf(coSync)) + val result = encodeAndDecodeDomainEvent(wrapper) + + assertEquals(domain.payload.subjectId, result.payload.subjectId) + assertEquals(domain.payload.projectId, result.payload.projectId) + assertEquals(domain.payload.moduleId, result.payload.moduleId) + assertEquals(domain.payload.attendantId, result.payload.attendantId) + + val faceRef = assertIs(result.payload.biometricReferences[0]) + assertEquals("face-ref-1", faceRef.id) + assertEquals("NEC_5", faceRef.format) + assertEquals("dGVtcGxhdGU=", faceRef.templates.first().template) + assertEquals(mapOf("key" to "value"), faceRef.metadata) + + val fpRef = assertIs(result.payload.biometricReferences[1]) + assertEquals("fp-ref-1", fpRef.id) + assertEquals("ISO_19794_2", fpRef.format) + assertEquals(TemplateIdentifier.LEFT_INDEX_FINGER, fpRef.templates.first().finger) + + val cred = result.payload.externalCredentials.single() + assertEquals("cred-1", cred.id) + assertEquals(TokenizableString.Tokenized("encrypted-value"), cred.value) + assertEquals(ExternalCredentialType.NHISCard, cred.type) + } + + // endregion + + // region Pinned JSON contract + + @Test + fun `V1 serialization matches pinned JSON contract`() { + val wrapper = CoSyncEnrolmentRecordEventsV1( + events = listOf( + CoSyncEnrolmentRecordCreationEvent( + id = "event-1", + payload = CoSyncEnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = CoSyncTokenizableString.Tokenized("encrypted-module"), + attendantId = CoSyncTokenizableString.Raw("attendant-1"), + biometricReferences = listOf( + CoSyncFaceReference( + id = "ref-1", + templates = listOf(CoSyncFaceTemplate(template = "dGVtcGxhdGU=")), + format = "NEC_5", + ), + ), + ), + ), + ), + ) + + val json = SimJson.encodeToString(wrapper) + + // If this test fails, someone changed the serialization format — update pinned JSON deliberately + val parsed = SimJson.parseToJsonElement(json) + val pinned = SimJson.parseToJsonElement(pinnedV1Json) + assertEquals(pinned, parsed) + } + + @Test + fun `pinned JSON deserializes to correct domain event`() { + val event = decodeDomainEvent(pinnedV1Json) + + assertEquals("event-1", event.id) + assertEquals("subject-1", event.payload.subjectId) + assertEquals("project-1", event.payload.projectId) + assertEquals(TokenizableString.Tokenized("encrypted-module"), event.payload.moduleId) + assertEquals(TokenizableString.Raw("attendant-1"), event.payload.attendantId) + + val ref = assertIs(event.payload.biometricReferences.single()) + assertEquals("ref-1", ref.id) + assertEquals("NEC_5", ref.format) + assertEquals("dGVtcGxhdGU=", ref.templates.first().template) + } + + // endregion + + // region Edge cases + + @Test + fun `toCoSync throws for unsupported event type`() { + val deletionEvent = EnrolmentRecordDeletionEvent( + subjectId = "subject-1", + projectId = "project-1", + moduleId = "module-1", + attendantId = "attendant-1", + ) + + assertFailsWith { + deletionEvent.toCoSync() + } + } + + @Test + fun `CoSyncExternalCredential toDomain throws for Raw value`() { + val credential = CoSyncExternalCredential( + id = "cred-1", + value = CoSyncTokenizableString.Raw("plain-value"), + subjectId = "subject-1", + type = CoSyncExternalCredentialType.NHISCard, + ) + + assertFailsWith { + credential.toDomain() + } + } + + // endregion + + // region Helpers + + /** + * Pinned JSON contract for V1 serialization format. + * If this needs updating, the protocol has changed — verify backward compatibility. + */ + private val pinnedV1Json = + """ + {"schemaVersion":"1.0","events":[{"type":"EnrolmentRecordCreation","id":"event-1","payload":{"subjectId":"subject-1","projectId":"project-1","moduleId":{"className":"TokenizableString.Tokenized","value":"encrypted-module"},"attendantId":{"className":"TokenizableString.Raw","value":"attendant-1"},"biometricReferences":[{"type":"FACE_REFERENCE","id":"ref-1","templates":[{"template":"dGVtcGxhdGU="}],"format":"NEC_5"}],"externalCredentials":[]}}]} + """.trimIndent() + + private fun buildCoSyncJson( + schemaVersion: String? = "1.0", + moduleId: String = "\"module-1\"", + attendantId: String = "\"attendant-1\"", + biometricReferences: String = "[]", + externalCredentials: String? = null, + ): String { + val versionLine = schemaVersion?.let { """"schemaVersion": "$it",""" } ?: "" + val credentialsLine = externalCredentials?.let { """"externalCredentials": $it,""" } ?: "" + return """ + { + $versionLine + "events": [ + { + "type": "EnrolmentRecordCreation", + "id": "event-1", + "payload": { + "subjectId": "subject-1", + "projectId": "project-1", + "moduleId": $moduleId, + "attendantId": $attendantId, + $credentialsLine + "biometricReferences": $biometricReferences + } + } + ] + } + """.trimIndent() + } + + private fun buildSimpleV1Event() = CoSyncEnrolmentRecordEventsV1( + events = listOf( + CoSyncEnrolmentRecordCreationEvent( + id = "event-1", + payload = CoSyncEnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = CoSyncTokenizableString.Raw("module-1"), + attendantId = CoSyncTokenizableString.Raw("attendant-1"), + ), + ), + ), + ) + + private fun decodeDomainEvent(json: String): EnrolmentRecordCreationEvent { + val result = SimJson.decodeFromString(json) + val events = result.toDomainEvents() + assertEquals(1, events.size) + return assertIs(events.first()) + } + + private fun encodeAndDecodeDomainEvent(original: CoSyncEnrolmentRecordEvents): EnrolmentRecordCreationEvent { + val json = SimJson.encodeToString(original) + return decodeDomainEvent(json) + } + + // endregion +}