diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt index f0dc8fad66..d9fa74d26b 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt @@ -1,9 +1,6 @@ package com.simprints.feature.clientapi.usecases import com.fasterxml.jackson.databind.module.SimpleModule -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameDeserializer -import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameSerializer import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.utils.EncodingUtils import com.simprints.infra.config.store.models.canCoSyncAllData @@ -12,7 +9,11 @@ import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery -import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEvents +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvents +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1 +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1Deserializer +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1Serializer +import com.simprints.infra.events.event.cosync.v1.toCoSyncV1 import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent import com.simprints.infra.logging.Simber import javax.inject.Inject @@ -46,7 +47,9 @@ internal class GetEnrolmentCreationEventForSubjectUseCase @Inject constructor( return null } - return jsonHelper.toJson(CoSyncEnrolmentRecordEvents(listOf(recordCreationEvent)), coSyncSerializationModule) + // Convert to V1 external schema before serialization for stable contract + val v1Events = EnrolmentRecordEvents(listOf(recordCreationEvent)).toCoSyncV1() + return jsonHelper.toJson(v1Events, coSyncSerializationModule) } private fun Subject.fromSubjectToEnrolmentCreationEvent() = EnrolmentRecordCreationEvent( @@ -60,8 +63,8 @@ internal class GetEnrolmentCreationEventForSubjectUseCase @Inject constructor( companion object { val coSyncSerializationModule = SimpleModule().apply { - addSerializer(TokenizableString::class.java, TokenizationClassNameSerializer()) - addDeserializer(TokenizableString::class.java, TokenizationClassNameDeserializer()) + addSerializer(TokenizableStringV1::class.java, TokenizableStringV1Serializer()) + addDeserializer(TokenizableStringV1::class.java, TokenizableStringV1Deserializer()) } } } diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt index a6c0619363..281cd1c4ee 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt @@ -11,9 +11,6 @@ import com.simprints.core.DispatcherBG import com.simprints.core.domain.common.Modality import com.simprints.core.domain.sample.Identity import com.simprints.core.domain.sample.Sample -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameDeserializer -import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameSerializer import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.utils.EncodingUtils @@ -25,8 +22,13 @@ import com.simprints.infra.enrolment.records.repository.domain.models.BiometricD import com.simprints.infra.enrolment.records.repository.domain.models.IdentityBatch import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.enrolment.records.repository.usecases.CompareImplicitTokenizedStringsUseCase -import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordCreationEventDeserializer -import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEvents +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordCreationEventV1 +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordCreationEventV1Deserializer +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordEventsV1 +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1 +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1Deserializer +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1Serializer +import com.simprints.infra.events.event.cosync.v1.toDomain import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent import com.simprints.infra.events.event.domain.models.subject.FaceReference import com.simprints.infra.events.event.domain.models.subject.FingerprintReference @@ -254,11 +256,12 @@ internal class CommCareIdentityDataSource @Inject constructor( private fun parseRecordEvents(subjectActions: String) = subjectActions.takeIf(String::isNotEmpty)?.let { try { - jsonHelper.fromJson( + val v1Events = jsonHelper.fromJson( json = it, module = coSyncSerializationModule, - type = object : TypeReference() {}, + type = object : TypeReference() {}, ) + v1Events.toDomain() } catch (e: Exception) { Simber.e("Error while parsing subjectActions", e) null @@ -267,16 +270,16 @@ internal class CommCareIdentityDataSource @Inject constructor( private val coSyncSerializationModule = SimpleModule().apply { addSerializer( - TokenizableString::class.java, - TokenizationClassNameSerializer(), + TokenizableStringV1::class.java, + TokenizableStringV1Serializer(), ) addDeserializer( - TokenizableString::class.java, - TokenizationClassNameDeserializer(), + TokenizableStringV1::class.java, + TokenizableStringV1Deserializer(), ) addDeserializer( - EnrolmentRecordCreationEvent::class.java, - CoSyncEnrolmentRecordCreationEventDeserializer(), + CoSyncEnrolmentRecordCreationEventV1::class.java, + CoSyncEnrolmentRecordCreationEventV1Deserializer(), ) } 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 69a6d38293..fbdd1b38ef 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 @@ -5,13 +5,15 @@ import android.database.Cursor import androidx.core.net.toUri import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.module.SimpleModule -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameDeserializer -import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameSerializer import com.simprints.core.tools.json.JsonHelper import com.simprints.infra.config.store.LastCallingPackageStore -import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordCreationEventDeserializer -import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEvents +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordCreationEventV1 +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordCreationEventV1Deserializer +import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordEventsV1 +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1 +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1Deserializer +import com.simprints.infra.events.event.cosync.v1.TokenizableStringV1Serializer +import com.simprints.infra.events.event.cosync.v1.toDomain import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordDeletionEvent import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvent @@ -228,11 +230,12 @@ internal class CommCareEventDataSource @Inject constructor( private fun parseRecordEvents(subjectActions: String) = subjectActions.takeIf(String::isNotEmpty)?.let { try { - jsonHelper.fromJson( + val v1Events = jsonHelper.fromJson( json = it, module = coSyncSerializationModule, - type = object : TypeReference() {}, + type = object : TypeReference() {}, ) + v1Events.toDomain() } catch (e: Exception) { Simber.e("Error while parsing subjectActions", e) null @@ -300,16 +303,16 @@ internal class CommCareEventDataSource @Inject constructor( private val coSyncSerializationModule = SimpleModule().apply { addSerializer( - TokenizableString::class.java, - TokenizationClassNameSerializer(), + TokenizableStringV1::class.java, + TokenizableStringV1Serializer(), ) addDeserializer( - TokenizableString::class.java, - TokenizationClassNameDeserializer(), + TokenizableStringV1::class.java, + TokenizableStringV1Deserializer(), ) addDeserializer( - EnrolmentRecordCreationEvent::class.java, - CoSyncEnrolmentRecordCreationEventDeserializer(), + CoSyncEnrolmentRecordCreationEventV1::class.java, + CoSyncEnrolmentRecordCreationEventV1Deserializer(), ) } diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializer.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializer.kt deleted file mode 100644 index df5939165b..0000000000 --- a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializer.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.simprints.infra.events.event.cosync - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.infra.events.event.domain.models.subject.BiometricReference -import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent - -/** - * Deserializer for [EnrolmentRecordCreationEvent] that reads the JSON node and constructs the - * [EnrolmentRecordCreationEvent] object. - * Accounts for past versions of the event where moduleId and attendantId were plain strings. - */ -class CoSyncEnrolmentRecordCreationEventDeserializer : - StdDeserializer( - EnrolmentRecordCreationEvent::class.java, - ) { - override fun deserialize( - p: JsonParser, - ctxt: DeserializationContext, - ): EnrolmentRecordCreationEvent { - val node: JsonNode = p.codec.readTree(p) - val id = node["id"].asText() - val payload = node["payload"] - - val subjectId = payload["subjectId"].asText() - val projectId = payload["projectId"].asText() - - // Try to parse as TokenizableString first, fall back to plain String - val moduleId = try { - ctxt.readTreeAsValue(payload["moduleId"], TokenizableString::class.java) - } catch (_: Exception) { - TokenizableString.Raw(payload["moduleId"].asText()) - } - - // Try to parse as TokenizableString first, fall back to plain String - val attendantId = try { - ctxt.readTreeAsValue(payload["attendantId"], TokenizableString::class.java) - } catch (_: Exception) { - TokenizableString.Raw(payload["attendantId"].asText()) - } - - val biometricReferences = ctxt.readTreeAsValue>( - payload["biometricReferences"], - ctxt.typeFactory.constructCollectionType(List::class.java, BiometricReference::class.java), - ) - - return EnrolmentRecordCreationEvent( - id, - EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( - subjectId = subjectId, - projectId = projectId, - moduleId = moduleId, - attendantId = attendantId, - biometricReferences = biometricReferences, - // TODO [CORE-3421] Update when CoSync supports external credentials (MfID) - externalCredentials = emptyList() - ), - ) - } -} 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 deleted file mode 100644 index afba92c8aa..0000000000 --- a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordEvents.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.simprints.infra.events.event.cosync - -import androidx.annotation.Keep -import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvent - -@Keep -data class CoSyncEnrolmentRecordEvents( - val events: List, -) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/BiometricReferenceV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/BiometricReferenceV1.kt new file mode 100644 index 0000000000..d637f0c639 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/BiometricReferenceV1.kt @@ -0,0 +1,111 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.simprints.infra.events.event.domain.models.subject.BiometricReference +import com.simprints.infra.events.event.domain.models.subject.FaceReference +import com.simprints.infra.events.event.domain.models.subject.FingerprintReference + +/** + * V1 external schema for biometric references (polymorphic base type). + * + * Uses Jackson polymorphic serialization with "type" discriminator field. + */ +@Keep +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = FaceReferenceV1::class, name = "FACE_REFERENCE"), + JsonSubTypes.Type(value = FingerprintReferenceV1::class, name = "FINGERPRINT_REFERENCE"), +) +@JsonInclude(JsonInclude.Include.NON_NULL) +sealed class BiometricReferenceV1( + open val id: String, + open val format: String, + val type: String, +) + +/** + * V1 face reference with face templates. + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class FaceReferenceV1( + override val id: String, + val templates: List, + override val format: String, + val metadata: Map? = null, +) : BiometricReferenceV1(id, format, "FACE_REFERENCE") + +/** + * V1 fingerprint reference with fingerprint templates. + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class FingerprintReferenceV1( + override val id: String, + val templates: List, + override val format: String, + val metadata: Map? = null, +) : BiometricReferenceV1(id, format, "FINGERPRINT_REFERENCE") + +/** + * Converts internal BiometricReference to V1 external schema. + */ +fun BiometricReference.toCoSyncV1(): BiometricReferenceV1 = when (this) { + is FaceReference -> this.toCoSyncV1() + is FingerprintReference -> this.toCoSyncV1() +} + +/** + * Converts V1 external schema to internal BiometricReference. + */ +fun BiometricReferenceV1.toDomain(): BiometricReference = when (this) { + is FaceReferenceV1 -> this.toDomain() + is FingerprintReferenceV1 -> this.toDomain() +} + +/** + * Converts internal FaceReference to V1 external schema. + */ +fun FaceReference.toCoSyncV1() = FaceReferenceV1( + id = id, + templates = templates.map { it.toCoSyncV1() }, + format = format, + metadata = metadata, +) + +/** + * Converts V1 external schema to internal FaceReference. + */ +fun FaceReferenceV1.toDomain() = FaceReference( + id = id, + templates = templates.map { it.toDomain() }, + format = format, + metadata = metadata, +) + +/** + * Converts internal FingerprintReference to V1 external schema. + */ +fun FingerprintReference.toCoSyncV1() = FingerprintReferenceV1( + id = id, + templates = templates.map { it.toCoSyncV1() }, + format = format, + metadata = metadata, +) + +/** + * Converts V1 external schema to internal FingerprintReference. + */ +fun FingerprintReferenceV1.toDomain() = FingerprintReference( + id = id, + templates = templates.map { it.toDomain() }, + format = format, + metadata = metadata, +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1.kt new file mode 100644 index 0000000000..44f2dffb69 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1.kt @@ -0,0 +1,99 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.fasterxml.jackson.annotation.JsonInclude +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent + +/** + * V1 external schema for enrolment record creation event. + * + * This represents the stable external contract for biometric enrolment data + * sent to CommCare's subjectActions field. + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CoSyncEnrolmentRecordCreationEventV1( + override val id: String, + val payload: CoSyncEnrolmentRecordCreationPayloadV1, +) : CoSyncEnrolmentRecordEventV1(id, "EnrolmentRecordCreation") + +/** + * V1 payload for enrolment record creation event. + * + * Field names and types MUST remain stable for compatibility. + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CoSyncEnrolmentRecordCreationPayloadV1( + /** + * Unique identifier for the enrolled subject. + */ + val subjectId: String, + /** + * Project identifier. + */ + val projectId: String, + /** + * Module identifier. Can be plain string or TokenizableStringV1. + * Supports both old format (plain string) and new format (TokenizableStringV1 object). + */ + val moduleId: TokenizableStringV1, + /** + * Attendant identifier. Can be plain string or TokenizableStringV1. + * Supports both old format (plain string) and new format (TokenizableStringV1 object). + */ + val attendantId: TokenizableStringV1, + /** + * List of biometric references (face/fingerprint templates). + */ + val biometricReferences: List, + /** + * Optional list of external credentials (e.g., MFID). + * Empty list if not applicable. + */ + val externalCredentials: List? = null, +) + +/** + * Converts internal EnrolmentRecordCreationEvent to V1 external schema. + */ +fun EnrolmentRecordCreationEvent.toCoSyncV1() = CoSyncEnrolmentRecordCreationEventV1( + id = id, + payload = payload.toCoSyncV1(), +) + +/** + * Converts V1 external schema to internal EnrolmentRecordCreationEvent. + */ +fun CoSyncEnrolmentRecordCreationEventV1.toDomain() = EnrolmentRecordCreationEvent( + id = id, + payload = payload.toDomain(), +) + +/** + * Converts internal payload to V1 external schema. + */ +fun EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload.toCoSyncV1() = CoSyncEnrolmentRecordCreationPayloadV1( + subjectId = subjectId, + projectId = projectId, + moduleId = moduleId.toCoSyncV1(), + attendantId = attendantId.toCoSyncV1(), + biometricReferences = biometricReferences.map { it.toCoSyncV1() }, + externalCredentials = if (externalCredentials.isNotEmpty()) { + externalCredentials.map { it.toCoSyncV1() } + } else { + null + }, +) + +/** + * Converts V1 external schema to internal payload. + */ +fun CoSyncEnrolmentRecordCreationPayloadV1.toDomain() = EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( + subjectId = subjectId, + projectId = projectId, + moduleId = moduleId.toDomain(), + attendantId = attendantId.toDomain(), + biometricReferences = biometricReferences.map { it.toDomain() }, + externalCredentials = externalCredentials?.map { it.toDomain() } ?: emptyList(), +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1Deserializer.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1Deserializer.kt new file mode 100644 index 0000000000..fa8998b6d6 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1Deserializer.kt @@ -0,0 +1,73 @@ +package com.simprints.infra.events.event.cosync.v1 + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer + +/** + * Custom deserializer for CoSyncEnrolmentRecordCreationEventV1 that handles + * backward compatibility with old JSON formats. + * + * This deserializer handles the TokenizableStringV1 migration: + * - Old format: "moduleId": "plain-string" + * - Middle format (no className): "moduleId": {"value": "..."} + * - New format: "moduleId": {"className": "TokenizableString.Tokenized", "value": "..."} + */ +class CoSyncEnrolmentRecordCreationEventV1Deserializer : + StdDeserializer(CoSyncEnrolmentRecordCreationEventV1::class.java) { + override fun deserialize( + p: JsonParser, + ctxt: DeserializationContext, + ): CoSyncEnrolmentRecordCreationEventV1 { + val node: JsonNode = p.codec.readTree(p) + val id = node["id"].asText() + val payload = node["payload"] + + // Parse subjectId and projectId as plain strings + val subjectId = payload["subjectId"].asText() + val projectId = payload["projectId"].asText() + + // Parse moduleId - try as TokenizableStringV1 first, fall back to plain string + val moduleId = try { + ctxt.readTreeAsValue(payload["moduleId"], TokenizableStringV1::class.java) + } catch (_: Exception) { + TokenizableStringV1.Raw(payload["moduleId"].asText()) + } + + // Parse attendantId - try as TokenizableStringV1 first, fall back to plain string + val attendantId = try { + ctxt.readTreeAsValue(payload["attendantId"], TokenizableStringV1::class.java) + } catch (_: Exception) { + TokenizableStringV1.Raw(payload["attendantId"].asText()) + } + + // Parse biometric references + val biometricReferences = payload["biometricReferences"]?.let { + ctxt.readTreeAsValue( + it, + ctxt.typeFactory.constructCollectionType(List::class.java, BiometricReferenceV1::class.java), + ) as List + } ?: emptyList() + + // Parse external credentials (optional field) + val externalCredentials = payload["externalCredentials"]?.let { + ctxt.readTreeAsValue( + it, + ctxt.typeFactory.constructCollectionType(List::class.java, ExternalCredentialV1::class.java), + ) as List + } + + return CoSyncEnrolmentRecordCreationEventV1( + id = id, + payload = CoSyncEnrolmentRecordCreationPayloadV1( + subjectId = subjectId, + projectId = projectId, + moduleId = moduleId, + attendantId = attendantId, + biometricReferences = biometricReferences, + externalCredentials = externalCredentials, + ), + ) + } +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventV1.kt new file mode 100644 index 0000000000..6330d399dd --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventV1.kt @@ -0,0 +1,41 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvent + +/** + * V1 external schema for enrolment record events (polymorphic base type). + * + * Uses Jackson polymorphic serialization with "type" discriminator field. + */ +@Keep +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = CoSyncEnrolmentRecordCreationEventV1::class, name = "EnrolmentRecordCreation"), +) +sealed class CoSyncEnrolmentRecordEventV1( + open val id: String, + val type: String, +) + +/** + * Converts internal EnrolmentRecordEvent to V1 external schema. + */ +fun EnrolmentRecordEvent.toCoSyncV1(): CoSyncEnrolmentRecordEventV1 = when (this) { + is EnrolmentRecordCreationEvent -> this.toCoSyncV1() + else -> throw IllegalArgumentException("Unsupported event type for V1: ${this::class.simpleName}") +} + +/** + * Converts V1 external schema to internal EnrolmentRecordEvent. + */ +fun CoSyncEnrolmentRecordEventV1.toDomain(): EnrolmentRecordEvent = when (this) { + is CoSyncEnrolmentRecordCreationEventV1 -> this.toDomain() +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventsV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventsV1.kt new file mode 100644 index 0000000000..96781842c2 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventsV1.kt @@ -0,0 +1,60 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvents + +/** + * V1 external schema for CoSync enrolment record events. + * + * This is a stable external contract that is decoupled from internal domain models. + * Any changes to this schema MUST maintain backward and forward compatibility, or + * require a new version (V2, V3, etc.). + * + * Compatibility requirements: + * - Forward compatibility: Old apps must be able to parse data produced by new apps + * - Backward compatibility: New apps must be able to parse data produced by old apps + * + * To ensure forward compatibility: + * - Never remove or rename existing fields + * - Only add new optional fields with defaults + * - Keep field types stable + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CoSyncEnrolmentRecordEventsV1( + /** + * Schema version for this external contract. Default is "1.0" for V1. + * + * This field is optional when deserializing to support old unversioned data. + * Old apps will ignore this field when deserializing new data. + */ + @JsonProperty("schemaVersion") + val schemaVersion: String? = SCHEMA_VERSION, + + /** + * List of enrolment record events. Currently only supports EnrolmentRecordCreation. + */ + @JsonProperty("events") + val events: List, +) { + companion object { + const val SCHEMA_VERSION = "1.0" + } +} + +/** + * Converts internal EnrolmentRecordEvents to V1 external schema. + */ +fun EnrolmentRecordEvents.toCoSyncV1() = CoSyncEnrolmentRecordEventsV1( + schemaVersion = CoSyncEnrolmentRecordEventsV1.SCHEMA_VERSION, + events = events.map { it.toCoSyncV1() }, +) + +/** + * Converts V1 external schema to internal EnrolmentRecordEvents. + */ +fun CoSyncEnrolmentRecordEventsV1.toDomain() = EnrolmentRecordEvents( + events = events.map { it.toDomain() }, +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/ExternalCredentialTypeV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/ExternalCredentialTypeV1.kt new file mode 100644 index 0000000000..1ab00e1def --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/ExternalCredentialTypeV1.kt @@ -0,0 +1,35 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.core.domain.externalcredential.ExternalCredentialType + +/** + * V1 external schema for external credential types. + * + * Represents the type of external credential (e.g., ID cards, QR codes). + * This is a stable external contract decoupled from internal domain models. + */ +@Keep +enum class ExternalCredentialTypeV1 { + NHISCard, + GhanaIdCard, + QRCode, +} + +/** + * Converts internal ExternalCredentialType to V1 external schema. + */ +fun ExternalCredentialType.toCoSyncV1(): ExternalCredentialTypeV1 = when (this) { + ExternalCredentialType.NHISCard -> ExternalCredentialTypeV1.NHISCard + ExternalCredentialType.GhanaIdCard -> ExternalCredentialTypeV1.GhanaIdCard + ExternalCredentialType.QRCode -> ExternalCredentialTypeV1.QRCode +} + +/** + * Converts V1 external schema to internal ExternalCredentialType. + */ +fun ExternalCredentialTypeV1.toDomain(): ExternalCredentialType = when (this) { + ExternalCredentialTypeV1.NHISCard -> ExternalCredentialType.NHISCard + ExternalCredentialTypeV1.GhanaIdCard -> ExternalCredentialType.GhanaIdCard + ExternalCredentialTypeV1.QRCode -> ExternalCredentialType.QRCode +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/ExternalCredentialV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/ExternalCredentialV1.kt new file mode 100644 index 0000000000..6fd3cef0ee --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/ExternalCredentialV1.kt @@ -0,0 +1,51 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.fasterxml.jackson.annotation.JsonInclude +import com.simprints.core.domain.externalcredential.ExternalCredential + +/** + * V1 external schema for external credentials (e.g., MFID). + * + * Represents external identifiers associated with the enrolled subject. + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ExternalCredentialV1( + /** + * Unique identifier for this credential. + */ + val id: String, + /** + * Tokenized credential value (encrypted). + */ + val value: TokenizableStringV1.Tokenized, + /** + * Subject ID this credential belongs to. + */ + val subjectId: String, + /** + * Type of external credential (e.g. GhanaIdCard). + */ + val type: ExternalCredentialTypeV1, +) + +/** + * Converts internal ExternalCredential to V1 external schema. + */ +fun ExternalCredential.toCoSyncV1() = ExternalCredentialV1( + id = id, + value = value.toCoSyncV1() as TokenizableStringV1.Tokenized, + subjectId = subjectId, + type = type.toCoSyncV1(), +) + +/** + * Converts V1 external schema to internal ExternalCredential. + */ +fun ExternalCredentialV1.toDomain() = ExternalCredential( + id = id, + value = value.toDomain() as com.simprints.core.domain.tokenization.TokenizableString.Tokenized, + subjectId = subjectId, + type = type.toDomain(), +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/SampleIdentifierV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/SampleIdentifierV1.kt new file mode 100644 index 0000000000..60954d1c3e --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/SampleIdentifierV1.kt @@ -0,0 +1,61 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.core.domain.sample.SampleIdentifier + +/** + * V1 external schema for sample identifiers. + * + * Represents which finger a fingerprint sample belongs to. + * This is a stable external contract decoupled from internal domain models. + */ +@Keep +enum class SampleIdentifierV1 { + NONE, + + // Fingerprint specific identifiers + 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, +} + +/** + * Converts internal SampleIdentifier to V1 external schema. + */ +fun SampleIdentifier.toCoSyncV1(): SampleIdentifierV1 = when (this) { + SampleIdentifier.NONE -> SampleIdentifierV1.NONE + SampleIdentifier.RIGHT_5TH_FINGER -> SampleIdentifierV1.RIGHT_5TH_FINGER + SampleIdentifier.RIGHT_4TH_FINGER -> SampleIdentifierV1.RIGHT_4TH_FINGER + SampleIdentifier.RIGHT_3RD_FINGER -> SampleIdentifierV1.RIGHT_3RD_FINGER + SampleIdentifier.RIGHT_INDEX_FINGER -> SampleIdentifierV1.RIGHT_INDEX_FINGER + SampleIdentifier.RIGHT_THUMB -> SampleIdentifierV1.RIGHT_THUMB + SampleIdentifier.LEFT_THUMB -> SampleIdentifierV1.LEFT_THUMB + SampleIdentifier.LEFT_INDEX_FINGER -> SampleIdentifierV1.LEFT_INDEX_FINGER + SampleIdentifier.LEFT_3RD_FINGER -> SampleIdentifierV1.LEFT_3RD_FINGER + SampleIdentifier.LEFT_4TH_FINGER -> SampleIdentifierV1.LEFT_4TH_FINGER + SampleIdentifier.LEFT_5TH_FINGER -> SampleIdentifierV1.LEFT_5TH_FINGER +} + +/** + * Converts V1 external schema to internal SampleIdentifier. + */ +fun SampleIdentifierV1.toDomain(): SampleIdentifier = when (this) { + SampleIdentifierV1.NONE -> SampleIdentifier.NONE + SampleIdentifierV1.RIGHT_5TH_FINGER -> SampleIdentifier.RIGHT_5TH_FINGER + SampleIdentifierV1.RIGHT_4TH_FINGER -> SampleIdentifier.RIGHT_4TH_FINGER + SampleIdentifierV1.RIGHT_3RD_FINGER -> SampleIdentifier.RIGHT_3RD_FINGER + SampleIdentifierV1.RIGHT_INDEX_FINGER -> SampleIdentifier.RIGHT_INDEX_FINGER + SampleIdentifierV1.RIGHT_THUMB -> SampleIdentifier.RIGHT_THUMB + SampleIdentifierV1.LEFT_THUMB -> SampleIdentifier.LEFT_THUMB + SampleIdentifierV1.LEFT_INDEX_FINGER -> SampleIdentifier.LEFT_INDEX_FINGER + SampleIdentifierV1.LEFT_3RD_FINGER -> SampleIdentifier.LEFT_3RD_FINGER + SampleIdentifierV1.LEFT_4TH_FINGER -> SampleIdentifier.LEFT_4TH_FINGER + SampleIdentifierV1.LEFT_5TH_FINGER -> SampleIdentifier.LEFT_5TH_FINGER +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TemplateV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TemplateV1.kt new file mode 100644 index 0000000000..4817776b16 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TemplateV1.kt @@ -0,0 +1,65 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.fasterxml.jackson.annotation.JsonInclude +import com.simprints.infra.events.event.domain.models.subject.FaceTemplate +import com.simprints.infra.events.event.domain.models.subject.FingerprintTemplate + +/** + * V1 external schema for face template. + * + * Contains base64-encoded biometric template data. + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class FaceTemplateV1( + /** + * Base64-encoded face template. + */ + val template: String, +) + +/** + * V1 external schema for fingerprint template. + * + * Contains base64-encoded biometric template data with finger identifier. + */ +@Keep +@JsonInclude(JsonInclude.Include.NON_NULL) +data class FingerprintTemplateV1( + /** + * Base64-encoded fingerprint template. + */ + val template: String, + + /** + * Identifier for which finger this template belongs to. + */ + val finger: SampleIdentifierV1, +) + +/** + * Converts internal FaceTemplate to V1 external schema. + */ +fun FaceTemplate.toCoSyncV1() = FaceTemplateV1(template = template) + +/** + * Converts V1 external schema to internal FaceTemplate. + */ +fun FaceTemplateV1.toDomain() = FaceTemplate(template = template) + +/** + * Converts internal FingerprintTemplate to V1 external schema. + */ +fun FingerprintTemplate.toCoSyncV1() = FingerprintTemplateV1( + template = template, + finger = finger.toCoSyncV1(), +) + +/** + * Converts V1 external schema to internal FingerprintTemplate. + */ +fun FingerprintTemplateV1.toDomain() = FingerprintTemplate( + template = template, + finger = finger.toDomain(), +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TokenizableStringV1.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TokenizableStringV1.kt new file mode 100644 index 0000000000..f074e9c74a --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TokenizableStringV1.kt @@ -0,0 +1,70 @@ +package com.simprints.infra.events.event.cosync.v1 + +import androidx.annotation.Keep +import com.simprints.core.domain.tokenization.TokenizableString + +/** + * V1 external schema for tokenizable strings. + * + * Sealed class for values that might be tokenized (symmetrically encrypted). + * Use this wrapping class when there is a need to differentiate between + * encrypted and unencrypted string values. + * + * This is a stable external contract decoupled from internal domain models. + */ +@Keep +sealed class TokenizableStringV1 { + abstract val value: String + + /** + * Represents a tokenized (encrypted) string value. + */ + data class Tokenized( + override val value: String, + ) : TokenizableStringV1() { + override fun hashCode() = super.hashCode() + + override fun equals(other: Any?) = super.equals(other) + + override fun toString() = super.toString() + } + + /** + * Represents a raw (unencrypted) string value. + */ + data class Raw( + override val value: String, + ) : TokenizableStringV1() { + override fun hashCode() = super.hashCode() + + override fun equals(other: Any?) = super.equals(other) + + override fun toString() = super.toString() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + return other is TokenizableStringV1 && other.value == value + } + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = value +} + +/** + * Converts internal TokenizableString to V1 external schema. + */ +fun TokenizableString.toCoSyncV1(): TokenizableStringV1 = when (this) { + is TokenizableString.Tokenized -> TokenizableStringV1.Tokenized(value) + is TokenizableString.Raw -> TokenizableStringV1.Raw(value) +} + +/** + * Converts V1 external schema to internal TokenizableString. + */ +fun TokenizableStringV1.toDomain(): TokenizableString = when (this) { + is TokenizableStringV1.Tokenized -> TokenizableString.Tokenized(value) + is TokenizableStringV1.Raw -> TokenizableString.Raw(value) +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TokenizableStringV1Serialization.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TokenizableStringV1Serialization.kt new file mode 100644 index 0000000000..7e7d3330fb --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/v1/TokenizableStringV1Serialization.kt @@ -0,0 +1,78 @@ +package com.simprints.infra.events.event.cosync.v1 + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +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" + +/** + * JSON serializer for [TokenizableStringV1] that creates an explicit specification + * of the child class being used. + * + * Examples: + * TokenizableStringV1.Raw(value = "person") + * -> { "className": "TokenizableString.Raw", "value": "person" } + * + * TokenizableStringV1.Tokenized(value = "eq2Efc98d") + * -> { "className": "TokenizableString.Tokenized", "value": "eq2Efc98d" } + */ +class TokenizableStringV1Serializer : StdSerializer(TokenizableStringV1::class.java) { + override fun serialize( + value: TokenizableStringV1, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + val className = when (value) { + is TokenizableStringV1.Raw -> RAW + is TokenizableStringV1.Tokenized -> TOKENIZED + } + gen.writeStartObject() + gen.writeStringField(FIELD_CLASS_NAME, className) + gen.writeStringField(FIELD_VALUE, value.value) + gen.writeEndObject() + } +} + +/** + * JSON deserializer for [TokenizableStringV1] that handles backward compatibility. + * + * Any json object without the explicit className specification will be resolved + * as [TokenizableStringV1.Raw] for backward compatibility. + * + * Examples: + * { "className": "TokenizableString.Raw", "value": "person" } + * -> TokenizableStringV1.Raw(value = "person") + * + * { "className": "TokenizableString.Tokenized", "value": "eq2Efc98d" } + * -> TokenizableStringV1.Tokenized(value = "eq2Efc98d") + * + * { "className": "Something else", "value": "name" } + * -> TokenizableStringV1.Raw(value = "name") + * + * { "value": "no class" } + * -> TokenizableStringV1.Raw(value = "no class") + */ +class TokenizableStringV1Deserializer : StdDeserializer(TokenizableStringV1::class.java) { + override fun deserialize( + p: JsonParser, + ctxt: DeserializationContext, + ): TokenizableStringV1 { + val node: JsonNode = p.codec.readTree(p) + + val className = node[FIELD_CLASS_NAME]?.asText() ?: "" + val value = node[FIELD_VALUE].asText() + + return when (className) { + TOKENIZED -> TokenizableStringV1.Tokenized(value) + else -> TokenizableStringV1.Raw(value) + } + } +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/subject/EnrolmentRecordEvents.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/subject/EnrolmentRecordEvents.kt new file mode 100644 index 0000000000..466812499b --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/subject/EnrolmentRecordEvents.kt @@ -0,0 +1,8 @@ +package com.simprints.infra.events.event.domain.models.subject + +import androidx.annotation.Keep + +@Keep +data class EnrolmentRecordEvents( + val events: List, +) diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/BiometricReferenceV1Test.kt b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/BiometricReferenceV1Test.kt new file mode 100644 index 0000000000..b8eeaf4a8c --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/BiometricReferenceV1Test.kt @@ -0,0 +1,98 @@ +package com.simprints.infra.events.event.cosync.v1 + +import com.simprints.core.domain.sample.SampleIdentifier +import com.simprints.infra.events.event.domain.models.subject.FaceReference +import com.simprints.infra.events.event.domain.models.subject.FaceTemplate +import com.simprints.infra.events.event.domain.models.subject.FingerprintReference +import com.simprints.infra.events.event.domain.models.subject.FingerprintTemplate +import org.junit.Test +import kotlin.test.assertEquals + +class BiometricReferenceV1Test { + + @Test + fun `face reference toCoSyncV1 and toDomain roundtrip preserves metadata`() { + // Given: face reference with metadata + val faceRef = FaceReference( + id = "face-1", + templates = listOf(FaceTemplate("template-1")), + format = "RANK_ONE", + metadata = mapOf("key1" to "value1", "key2" to "value2"), + ) + + // When: convert to V1 and back + val v1FaceRef = faceRef.toCoSyncV1() + val domainFaceRef = v1FaceRef.toDomain() + + // Then: metadata is preserved + assertEquals(faceRef.id, domainFaceRef.id) + assertEquals(faceRef.format, domainFaceRef.format) + assertEquals(faceRef.metadata, domainFaceRef.metadata) + assertEquals(faceRef.templates.size, domainFaceRef.templates.size) + } + + @Test + fun `fingerprint reference toCoSyncV1 and toDomain roundtrip preserves finger identifiers`() { + // Given: fingerprint reference with multiple templates + val fingerprintRef = FingerprintReference( + id = "fingerprint-1", + templates = listOf( + FingerprintTemplate("template-1", SampleIdentifier.LEFT_THUMB), + FingerprintTemplate("template-2", SampleIdentifier.RIGHT_INDEX_FINGER), + ), + format = "NEC", + metadata = null, + ) + + // When: convert to V1 and back + val v1FingerprintRef = fingerprintRef.toCoSyncV1() + val domainFingerprintRef = v1FingerprintRef.toDomain() + + // Then: finger identifiers are preserved + assertEquals(fingerprintRef.id, domainFingerprintRef.id) + assertEquals(fingerprintRef.format, domainFingerprintRef.format) + assertEquals(fingerprintRef.metadata, domainFingerprintRef.metadata) + assertEquals(2, domainFingerprintRef.templates.size) + assertEquals(SampleIdentifier.LEFT_THUMB, domainFingerprintRef.templates[0].finger) + assertEquals(SampleIdentifier.RIGHT_INDEX_FINGER, domainFingerprintRef.templates[1].finger) + } + + @Test + fun `face reference toCoSyncV1 preserves template data`() { + // Given: face reference with template + val faceRef = FaceReference( + id = "face-2", + templates = listOf( + FaceTemplate("base64-template-1"), + FaceTemplate("base64-template-2"), + ), + format = "SIMFACE", + metadata = null, + ) + + // When: convert to V1 + val v1FaceRef = faceRef.toCoSyncV1() + + // Then: all template data is preserved + assertEquals(2, v1FaceRef.templates.size) + assertEquals("base64-template-1", v1FaceRef.templates[0].template) + assertEquals("base64-template-2", v1FaceRef.templates[1].template) + } + + @Test + fun `fingerprint template V1 uses SampleIdentifierV1 type`() { + // Given: fingerprint reference with domain SampleIdentifier + val fingerprintRef = FingerprintReference( + id = "fingerprint-1", + templates = listOf(FingerprintTemplate("template-1", SampleIdentifier.LEFT_THUMB)), + format = "NEC", + metadata = null, + ) + + // When: convert to V1 + val v1FingerprintRef = fingerprintRef.toCoSyncV1() + + // Then: V1 model uses SampleIdentifierV1 + assertEquals(SampleIdentifierV1.LEFT_THUMB, v1FingerprintRef.templates[0].finger) + } +} 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/v1/CoSyncEnrolmentRecordCreationEventV1DeserializerTest.kt similarity index 67% rename from infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializerTest.kt rename to infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1DeserializerTest.kt index 4309cc4fe9..e3803f8053 100644 --- 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/v1/CoSyncEnrolmentRecordCreationEventV1DeserializerTest.kt @@ -1,18 +1,16 @@ -package com.simprints.infra.events.event.cosync +package com.simprints.infra.events.event.cosync.v1 import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.infra.events.event.domain.models.subject.BiometricReference import io.mockk.every import io.mockk.mockk import org.junit.Test import kotlin.test.assertEquals -class CoSyncEnrolmentRecordCreationEventDeserializerTest { - private val deserializer = CoSyncEnrolmentRecordCreationEventDeserializer() +class CoSyncEnrolmentRecordCreationEventV1DeserializerTest { + private val deserializer = CoSyncEnrolmentRecordCreationEventV1Deserializer() private val objectMapper = ObjectMapper() @Test @@ -21,7 +19,7 @@ class CoSyncEnrolmentRecordCreationEventDeserializerTest { val parser = objectMapper.createParser(json) val context = mockk() every { - context.readTreeAsValue>( + context.readTreeAsValue>( any(), any(), ) @@ -32,21 +30,21 @@ class CoSyncEnrolmentRecordCreationEventDeserializerTest { assertEquals(EVENT_ID, result.id) assertEquals(SUBJECT_ID, result.payload.subjectId) assertEquals(PROJECT_ID, result.payload.projectId) - assertEquals(TokenizableString.Raw(MODULE_ID), result.payload.moduleId) - assertEquals(TokenizableString.Raw(ATTENDANT_ID), result.payload.attendantId) - assertEquals(emptyList(), result.payload.biometricReferences) + assertEquals(TokenizableStringV1.Raw(MODULE_ID), result.payload.moduleId) + assertEquals(TokenizableStringV1.Raw(ATTENDANT_ID), result.payload.attendantId) + assertEquals(emptyList(), result.payload.biometricReferences) } @Test - fun `deserialize handles new format with TokenizableString`() { + fun `deserialize handles new format with TokenizableStringV1`() { val json = JSON_TEMPLATE.format(TOKENIZED_MODULE, RAW_ATTENDANT) val parser = objectMapper.createParser(json) val context = mockk() every { - context.readTreeAsValue(any(), TokenizableString::class.java) - } returns TokenizableString.Tokenized(ENCRYPTED_MODULE) andThen TokenizableString.Raw(UNENCRYPTED_ATTENDANT) + context.readTreeAsValue(any(), TokenizableStringV1::class.java) + } returns TokenizableStringV1.Tokenized(ENCRYPTED_MODULE) andThen TokenizableStringV1.Raw(UNENCRYPTED_ATTENDANT) every { - context.readTreeAsValue>( + context.readTreeAsValue>( any(), any(), ) @@ -57,21 +55,21 @@ class CoSyncEnrolmentRecordCreationEventDeserializerTest { assertEquals(EVENT_ID, result.id) assertEquals(SUBJECT_ID, result.payload.subjectId) assertEquals(PROJECT_ID, result.payload.projectId) - assertEquals(TokenizableString.Tokenized(ENCRYPTED_MODULE), result.payload.moduleId) - assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) - assertEquals(emptyList(), result.payload.biometricReferences) + assertEquals(TokenizableStringV1.Tokenized(ENCRYPTED_MODULE), result.payload.moduleId) + assertEquals(TokenizableStringV1.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) + assertEquals(emptyList(), result.payload.biometricReferences) } @Test - fun `deserialize handles new format with TokenizableString but without explicit class`() { + fun `deserialize handles new format with TokenizableStringV1 but without explicit class`() { val json = JSON_TEMPLATE.format(TOKENIZED_MODULE_NO_CLASS, RAW_ATTENDANT_NO_CLASS) val parser = objectMapper.createParser(json) val context = mockk() every { - context.readTreeAsValue(any(), TokenizableString::class.java) - } returns TokenizableString.Raw(ENCRYPTED_MODULE) andThen TokenizableString.Raw(UNENCRYPTED_ATTENDANT) + context.readTreeAsValue(any(), TokenizableStringV1::class.java) + } returns TokenizableStringV1.Raw(ENCRYPTED_MODULE) andThen TokenizableStringV1.Raw(UNENCRYPTED_ATTENDANT) every { - context.readTreeAsValue>( + context.readTreeAsValue>( any(), any(), ) @@ -82,9 +80,9 @@ class CoSyncEnrolmentRecordCreationEventDeserializerTest { assertEquals(EVENT_ID, result.id) assertEquals(SUBJECT_ID, result.payload.subjectId) assertEquals(PROJECT_ID, result.payload.projectId) - assertEquals(TokenizableString.Raw(ENCRYPTED_MODULE), result.payload.moduleId) - assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) - assertEquals(emptyList(), result.payload.biometricReferences) + assertEquals(TokenizableStringV1.Raw(ENCRYPTED_MODULE), result.payload.moduleId) + assertEquals(TokenizableStringV1.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) + assertEquals(emptyList(), result.payload.biometricReferences) } companion object { diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1Test.kt b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1Test.kt new file mode 100644 index 0000000000..5bc0860ea6 --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordCreationEventV1Test.kt @@ -0,0 +1,88 @@ +package com.simprints.infra.events.event.cosync.v1 + +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvents +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class CoSyncEnrolmentRecordCreationEventV1Test { + + @Test + fun `toCoSyncV1 with empty externalCredentials sets null`() { + // Given: internal domain model with empty credentials + val internalEvent = EnrolmentRecordCreationEvent( + id = "event-1", + payload = EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = TokenizableString.Raw("module-1"), + attendantId = TokenizableString.Tokenized("attendant-1"), + biometricReferences = emptyList(), + externalCredentials = emptyList(), + ), + ) + val internalWrapper = EnrolmentRecordEvents(listOf(internalEvent)) + + // When: convert to V1 + val v1Wrapper = internalWrapper.toCoSyncV1() + + // Then: externalCredentials is null (not empty list) + val v1Event = v1Wrapper.events.first() as CoSyncEnrolmentRecordCreationEventV1 + assertNull(v1Event.payload.externalCredentials) + } + + @Test + fun `toDomain with null externalCredentials creates empty list`() { + // Given: V1 model with null credentials + val v1Event = CoSyncEnrolmentRecordCreationEventV1( + id = "event-1", + payload = CoSyncEnrolmentRecordCreationPayloadV1( + subjectId = "subject-1", + projectId = "project-1", + moduleId = TokenizableStringV1.Raw("module-1"), + attendantId = TokenizableStringV1.Tokenized("attendant-1"), + biometricReferences = emptyList(), + externalCredentials = null, + ), + ) + val v1Wrapper = CoSyncEnrolmentRecordEventsV1(events = listOf(v1Event)) + + // When: convert to domain + val domainWrapper = v1Wrapper.toDomain() + + // Then: externalCredentials is empty list + val domainEvent = domainWrapper.events.first() as EnrolmentRecordCreationEvent + assertNotNull(domainEvent.payload.externalCredentials) + assertEquals(0, domainEvent.payload.externalCredentials.size) + } + + @Test + fun `toCoSyncV1 and toDomain roundtrip preserves all payload fields`() { + // Given: internal event with all fields populated + val internalEvent = EnrolmentRecordCreationEvent( + id = "event-123", + payload = EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( + subjectId = "subject-456", + projectId = "project-789", + moduleId = TokenizableString.Tokenized("encrypted-module"), + attendantId = TokenizableString.Raw("attendant-id"), + biometricReferences = emptyList(), + externalCredentials = emptyList(), + ), + ) + + // When: convert to V1 and back + val v1Event = internalEvent.toCoSyncV1() + val domainEvent = v1Event.toDomain() + + // Then: all fields are preserved + assertEquals(internalEvent.id, domainEvent.id) + assertEquals(internalEvent.payload.subjectId, domainEvent.payload.subjectId) + assertEquals(internalEvent.payload.projectId, domainEvent.payload.projectId) + assertEquals(internalEvent.payload.moduleId, domainEvent.payload.moduleId) + assertEquals(internalEvent.payload.attendantId, domainEvent.payload.attendantId) + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventsV1Test.kt b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventsV1Test.kt new file mode 100644 index 0000000000..8c0521c3f1 --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/CoSyncEnrolmentRecordEventsV1Test.kt @@ -0,0 +1,86 @@ +package com.simprints.infra.events.event.cosync.v1 + +import com.simprints.core.domain.externalcredential.ExternalCredential +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.sample.SampleIdentifier +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvents +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent +import com.simprints.infra.events.event.domain.models.subject.FaceReference +import com.simprints.infra.events.event.domain.models.subject.FaceTemplate +import com.simprints.infra.events.event.domain.models.subject.FingerprintReference +import com.simprints.infra.events.event.domain.models.subject.FingerprintTemplate +import org.junit.Test +import kotlin.test.assertEquals + +class CoSyncEnrolmentRecordEventsV1Test { + + @Test + fun `toCoSyncV1 and toDomain roundtrip preserves data`() { + // Given: internal domain model + val internalEvent = createInternalEvent() + val internalWrapper = EnrolmentRecordEvents(listOf(internalEvent)) + + // When: convert to V1 and back to domain + val v1Wrapper = internalWrapper.toCoSyncV1() + val domainWrapper = v1Wrapper.toDomain() + + // Then: data is preserved + assertEquals(1, domainWrapper.events.size) + val domainEvent = domainWrapper.events.first() as EnrolmentRecordCreationEvent + assertEquals(internalEvent.id, domainEvent.id) + assertEquals(internalEvent.payload.subjectId, domainEvent.payload.subjectId) + assertEquals(internalEvent.payload.projectId, domainEvent.payload.projectId) + assertEquals(internalEvent.payload.moduleId, domainEvent.payload.moduleId) + assertEquals(internalEvent.payload.attendantId, domainEvent.payload.attendantId) + assertEquals(2, domainEvent.payload.biometricReferences.size) + assertEquals(1, domainEvent.payload.externalCredentials.size) + } + + @Test + fun `toCoSyncV1 sets schemaVersion to 1_0`() { + // Given: internal domain model + val internalWrapper = EnrolmentRecordEvents(listOf(createInternalEvent())) + + // When: convert to V1 + val v1Wrapper = internalWrapper.toCoSyncV1() + + // Then: schemaVersion is set + assertEquals(CoSyncEnrolmentRecordEventsV1.SCHEMA_VERSION, v1Wrapper.schemaVersion) + assertEquals("1.0", v1Wrapper.schemaVersion) + } + + private fun createInternalEvent() = EnrolmentRecordCreationEvent( + id = "event-1", + payload = EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = TokenizableString.Raw("module-1"), + attendantId = TokenizableString.Tokenized("attendant-1"), + biometricReferences = listOf( + FaceReference( + id = "face-1", + templates = listOf(FaceTemplate("template-1")), + format = "RANK_ONE", + metadata = mapOf("quality" to "high"), + ), + FingerprintReference( + id = "fingerprint-1", + templates = listOf( + FingerprintTemplate("fp-template-1", SampleIdentifier.LEFT_THUMB), + ), + format = "NEC", + metadata = null, + ), + ), + externalCredentials = listOf( + ExternalCredential( + id = "cred-1", + value = TokenizableString.Tokenized("encrypted-value"), + subjectId = "subject-1", + type = ExternalCredentialType.NHISCard, + ), + ), + ), + ) +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/SerializationRoundtripTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/SerializationRoundtripTest.kt new file mode 100644 index 0000000000..01df8a7875 --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/v1/SerializationRoundtripTest.kt @@ -0,0 +1,216 @@ +package com.simprints.infra.events.event.cosync.v1 + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +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.domain.models.subject.EnrolmentRecordEvents +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent +import com.simprints.infra.events.event.domain.models.subject.FaceReference +import com.simprints.infra.events.event.domain.models.subject.FaceTemplate +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SerializationRoundtripTest { + + private val objectMapper = ObjectMapper().apply { + // Register Kotlin module for proper Kotlin data class support + registerModule(KotlinModule.Builder().build()) + + // Configure to ignore unknown properties (like "className" in TokenizableStringV1) + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + val module = SimpleModule().apply { + addSerializer(TokenizableStringV1::class.java, TokenizableStringV1Serializer()) + addDeserializer(TokenizableStringV1::class.java, TokenizableStringV1Deserializer()) + addDeserializer(CoSyncEnrolmentRecordCreationEventV1::class.java, CoSyncEnrolmentRecordCreationEventV1Deserializer()) + } + registerModule(module) + } + + @Test + fun `serialize V1 and deserialize V1 preserves data`() { + // Given: internal event converted to V1 + val internalEvent = createInternalEvent() + val internalWrapper = EnrolmentRecordEvents(listOf(internalEvent)) + val v1Wrapper = internalWrapper.toCoSyncV1() + + // When: serialize to JSON and deserialize back + val json = objectMapper.writeValueAsString(v1Wrapper) + val deserialized = objectMapper.readValue(json, CoSyncEnrolmentRecordEventsV1::class.java) + + // Then: data is preserved + assertEquals("1.0", deserialized.schemaVersion) + assertEquals(1, deserialized.events.size) + val event = deserialized.events.first() as CoSyncEnrolmentRecordCreationEventV1 + assertEquals(internalEvent.id, event.id) + assertEquals(internalEvent.payload.subjectId, event.payload.subjectId) + } + + @Test + fun `serialized JSON contains schemaVersion field`() { + // Given: internal event converted to V1 + val internalEvent = createInternalEvent() + val internalWrapper = EnrolmentRecordEvents(listOf(internalEvent)) + val v1Wrapper = internalWrapper.toCoSyncV1() + + // When: serialize to JSON + val json = objectMapper.writeValueAsString(v1Wrapper) + + // Then: JSON contains schemaVersion + assertTrue(json.contains("\"schemaVersion\":\"1.0\"")) + } + + @Test + fun `old format JSON without schemaVersion can be deserialized`() { + // Given: old format JSON without schemaVersion + val oldFormatJson = """ + { + "events": [ + { + "type": "EnrolmentRecordCreation", + "id": "event-1", + "payload": { + "subjectId": "subject-1", + "projectId": "project-1", + "moduleId": "module-1", + "attendantId": "attendant-1", + "biometricReferences": [] + } + } + ] + } + """.trimIndent() + + // When: deserialize + val deserialized = objectMapper.readValue(oldFormatJson, CoSyncEnrolmentRecordEventsV1::class.java) + + // Then: successfully parsed with null schemaVersion + assertNotNull(deserialized) + assertEquals(1, deserialized.events.size) + val event = deserialized.events.first() as CoSyncEnrolmentRecordCreationEventV1 + assertEquals("event-1", event.id) + assertEquals("subject-1", event.payload.subjectId) + } + + @Test + fun `old format with plain string moduleId and attendantId can be deserialized`() { + // Given: old format with plain strings (pre-TokenizableString) + val oldFormatJson = """ + { + "events": [ + { + "type": "EnrolmentRecordCreation", + "id": "event-1", + "payload": { + "subjectId": "subject-1", + "projectId": "project-1", + "moduleId": "plain-module-id", + "attendantId": "plain-attendant-id", + "biometricReferences": [] + } + } + ] + } + """.trimIndent() + + // When: deserialize + val deserialized = objectMapper.readValue(oldFormatJson, CoSyncEnrolmentRecordEventsV1::class.java) + + // Then: successfully parsed with TokenizableStringV1.Raw + val event = deserialized.events.first() as CoSyncEnrolmentRecordCreationEventV1 + assertEquals(TokenizableStringV1.Raw("plain-module-id"), event.payload.moduleId) + assertEquals(TokenizableStringV1.Raw("plain-attendant-id"), event.payload.attendantId) + } + + @Test + fun `new format with TokenizableStringV1 can be deserialized`() { + // Given: new format with TokenizableStringV1 objects + val newFormatJson = """ + { + "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": "raw-attendant" + }, + "biometricReferences": [] + } + } + ] + } + """.trimIndent() + + // When: deserialize + val deserialized = objectMapper.readValue(newFormatJson, CoSyncEnrolmentRecordEventsV1::class.java) + + // Then: successfully parsed with correct TokenizableStringV1 types + val event = deserialized.events.first() as CoSyncEnrolmentRecordCreationEventV1 + assertEquals(TokenizableStringV1.Tokenized("encrypted-module"), event.payload.moduleId) + assertEquals(TokenizableStringV1.Raw("raw-attendant"), event.payload.attendantId) + } + + @Test + fun `roundtrip through domain conversion preserves data`() { + // Given: internal event + val internalEvent = createInternalEvent() + val internalWrapper = EnrolmentRecordEvents(listOf(internalEvent)) + + // When: convert to V1, serialize, deserialize, convert back to domain + val v1Wrapper = internalWrapper.toCoSyncV1() + val json = objectMapper.writeValueAsString(v1Wrapper) + val deserializedV1 = objectMapper.readValue(json, CoSyncEnrolmentRecordEventsV1::class.java) + val domainWrapper = deserializedV1.toDomain() + + // Then: data is preserved + val domainEvent = domainWrapper.events.first() as EnrolmentRecordCreationEvent + assertEquals(internalEvent.id, domainEvent.id) + assertEquals(internalEvent.payload.subjectId, domainEvent.payload.subjectId) + assertEquals(internalEvent.payload.projectId, domainEvent.payload.projectId) + assertEquals(internalEvent.payload.moduleId, domainEvent.payload.moduleId) + assertEquals(internalEvent.payload.attendantId, domainEvent.payload.attendantId) + assertEquals(1, domainEvent.payload.biometricReferences.size) + assertEquals(1, domainEvent.payload.externalCredentials.size) + } + + private fun createInternalEvent() = EnrolmentRecordCreationEvent( + id = "event-1", + payload = EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( + subjectId = "subject-1", + projectId = "project-1", + moduleId = TokenizableString.Raw("module-1"), + attendantId = TokenizableString.Tokenized("encrypted-attendant-1"), + biometricReferences = listOf( + FaceReference( + id = "face-1", + templates = listOf(FaceTemplate("template-1")), + format = "RANK_ONE", + metadata = mapOf("quality" to "high"), + ), + ), + externalCredentials = listOf( + ExternalCredential( + id = "cred-1", + value = TokenizableString.Tokenized("encrypted-value"), + subjectId = "subject-1", + type = ExternalCredentialType.NHISCard, + ), + ), + ), + ) +}