diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt index 17f3964b03..c3afc6e5cc 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt @@ -261,7 +261,7 @@ internal class EventUpSyncTask @Inject constructor( .also { listOfEvents -> emit(listOfEvents.size) } } catch (ex: Exception) { if (ex is JsonParseException || ex is JsonMappingException) { - Simber.i("Failed to un-marshal events", ex, tag = SYNC) + Simber.e("Failed to un-marshal events", ex, tag = SYNC) } else { throw ex } diff --git a/infra/events/schemas/com.simprints.infra.events.event.local.EventRoomDatabase/17.json b/infra/events/schemas/com.simprints.infra.events.event.local.EventRoomDatabase/17.json new file mode 100644 index 0000000000..2313cbd326 --- /dev/null +++ b/infra/events/schemas/com.simprints.infra.events.event.local.EventRoomDatabase/17.json @@ -0,0 +1,138 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "d5dd0e6fc8f6d48c5f58e7b191bc8d3d", + "entities": [ + { + "tableName": "DbEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `projectId` TEXT, `scopeId` TEXT, `eventJson` TEXT NOT NULL, `createdAt_unixMs` INTEGER NOT NULL, `createdAt_isTrustworthy` INTEGER NOT NULL, `createdAt_msSinceBoot` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT" + }, + { + "fieldPath": "scopeId", + "columnName": "scopeId", + "affinity": "TEXT" + }, + { + "fieldPath": "eventJson", + "columnName": "eventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt.unixMs", + "columnName": "createdAt_unixMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.isTrustworthy", + "columnName": "createdAt_isTrustworthy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.msSinceBoot", + "columnName": "createdAt_msSinceBoot", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "DbEventScope", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `projectId` TEXT NOT NULL, `type` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `start_unixMs` INTEGER NOT NULL, `start_isTrustworthy` INTEGER NOT NULL, `start_msSinceBoot` INTEGER, `end_unixMs` INTEGER, `end_isTrustworthy` INTEGER, `end_msSinceBoot` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt.unixMs", + "columnName": "start_unixMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.isTrustworthy", + "columnName": "start_isTrustworthy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.msSinceBoot", + "columnName": "start_msSinceBoot", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt.unixMs", + "columnName": "end_unixMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt.isTrustworthy", + "columnName": "end_isTrustworthy", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt.msSinceBoot", + "columnName": "end_msSinceBoot", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5dd0e6fc8f6d48c5f58e7b191bc8d3d')" + ] + } +} \ No newline at end of file diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt index 323dc422a4..fe71bc5f01 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt @@ -13,6 +13,7 @@ import com.simprints.infra.events.event.local.migrations.EventMigration12to13 import com.simprints.infra.events.event.local.migrations.EventMigration13to14 import com.simprints.infra.events.event.local.migrations.EventMigration14to15 import com.simprints.infra.events.event.local.migrations.EventMigration15to16 +import com.simprints.infra.events.event.local.migrations.EventMigration16to17 import com.simprints.infra.events.event.local.migrations.EventMigration1to2 import com.simprints.infra.events.event.local.migrations.EventMigration2to3 import com.simprints.infra.events.event.local.migrations.EventMigration3to4 @@ -31,7 +32,7 @@ import net.zetetic.database.sqlcipher.SupportOpenHelperFactory DbEvent::class, DbEventScope::class, ], - version = 16, + version = 17, exportSchema = true, ) @TypeConverters(Converters::class) @@ -64,6 +65,7 @@ internal abstract class EventRoomDatabase : RoomDatabase() { .addMigrations(EventMigration13to14()) .addMigrations(EventMigration14to15()) .addMigrations(EventMigration15to16()) + .addMigrations(EventMigration16to17()) if (BuildConfig.DB_ENCRYPTION) { builder.openHelperFactory(factory) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17.kt b/infra/events/src/main/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17.kt new file mode 100644 index 0000000000..545ff4fc37 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17.kt @@ -0,0 +1,67 @@ +package com.simprints.infra.events.event.local.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.simprints.core.tools.extentions.getStringWithColumnName +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MIGRATION +import com.simprints.infra.logging.Simber +import org.json.JSONArray +import org.json.JSONObject + +/** + * Starting from 2025.4.0, EnrolmentEventV4 requires the `externalCredentialIds` field. + * This migration adds an empty `externalCredentialIds` array field to the EnrolmentEventV4 event + * + * This migration adds: + * "externalCredentialIds": [], + * to the payload object. + */ +internal class EventMigration16to17 : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + Simber.i("Migrating room db from schema 16 to schema 17.", tag = MIGRATION) + migrateEnrolmentEventJson(db) + Simber.i("Migration from schema 16 to schema 17 done.", tag = MIGRATION) + } + + private fun migrateEnrolmentEventJson(database: SupportSQLiteDatabase) { + val eventsQuery = database.query( + "SELECT * FROM $DB_EVENT_ENTITY WHERE type = ?", + arrayOf(EVENT_TYPE_ENROLMENT_V4), + ) + eventsQuery.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getStringWithColumnName("id") ?: continue + val jsonData = cursor.getStringWithColumnName(DB_EVENT_JSON_FIELD) ?: continue + + try { + val jsonObject = JSONObject(jsonData) + val payload = jsonObject.optJSONObject(PAYLOAD_JSON_FIELD) ?: continue + + // Only adding if 'externalCredentialIds' field doesn't exist + if (!payload.has(EXTERNAL_CREDENTIAL_IDS_JSON_FIELD)) { + payload.put(EXTERNAL_CREDENTIAL_IDS_JSON_FIELD, JSONArray()) + val migratedJson = jsonObject.toString() + database.execSQL( + "UPDATE $DB_EVENT_ENTITY SET $DB_EVENT_JSON_FIELD = ? WHERE id = ?", + arrayOf(migratedJson, id), + ) + } + } catch (e: Exception) { + Simber.e( + "Failed to migrate room db from schema 16 to schema 17.", + e, + tag = MIGRATION, + ) + } + } + } + } + + companion object { + private const val DB_EVENT_ENTITY = "DbEvent" + private const val DB_EVENT_JSON_FIELD = "eventJson" + private const val EVENT_TYPE_ENROLMENT_V4 = "ENROLMENT_V4" + private const val EXTERNAL_CREDENTIAL_IDS_JSON_FIELD = "externalCredentialIds" + private const val PAYLOAD_JSON_FIELD = "payload" + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17Test.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17Test.kt new file mode 100644 index 0000000000..8038370fb0 --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17Test.kt @@ -0,0 +1,255 @@ +package com.simprints.infra.events.event.local.migrations + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.simprints.core.tools.extentions.getStringWithColumnName +import com.simprints.core.tools.utils.randomUUID +import com.simprints.infra.events.event.local.EventRoomDatabase +import io.mockk.spyk +import io.mockk.verify +import org.json.JSONObject +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class EventMigration16to17Test { + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + EventRoomDatabase::class.java, + ) + + @Test + @Throws(IOException::class) + fun `validate end to end migration is successful`() { + val eventId = randomUUID() + + setupV16DbWithEvent(eventId) + + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + val eventJson = MigrationTestingTools + .retrieveCursorWithEventById(db, eventId) + .getStringWithColumnName("eventJson")!! + + val jsonObject = JSONObject(eventJson) + val payload = jsonObject.getJSONObject("payload") + assertThat(eventJson).contains("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\":[]") + assertThat(payload.has(EXTERNAL_CREDENTIAL_IDS_JSON_KEY)).isTrue() + assertThat(payload.getJSONArray(EXTERNAL_CREDENTIAL_IDS_JSON_KEY).length()).isEqualTo(0) + + db.close() + } + + @Test + fun `validate migration is called`() { + val migrationSpy = spyk(EventMigration16to17()) + + setupV16DbWithEvent(randomUUID()) + helper.runMigrationsAndValidate(TEST_DB, 17, true, migrationSpy) + + verify(exactly = 1) { migrationSpy.migrate(any()) } + } + + @Test + fun `validate all ENROLMENT_V4 events are migrated`() { + val eventId1 = randomUUID() + val eventId2 = randomUUID() + + setupV16DbWithEvent(eventId1, eventId2) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + MigrationTestingTools.retrieveCursorWithEventById(db, eventId1).use { cursor -> + val eventJson = cursor.getStringWithColumnName("eventJson") + assertThat(eventJson).contains("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\":[]") + } + + MigrationTestingTools.retrieveCursorWithEventById(db, eventId2).use { cursor -> + val eventJson = cursor.getStringWithColumnName("eventJson") + assertThat(eventJson).contains("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\":[]") + } + + db.close() + } + + @Test + fun `validate migration query is called`() { + val migrationSpy = spyk(EventMigration16to17()) + + val db = spyk(setupV16DbWithEvent(randomUUID(), close = false)) + migrationSpy.migrate(db) + + verify(atLeast = 1) { db.query(any()) } + db.close() + } + + @Test + fun `validate migration does not add field if it already exists`() { + val eventId = randomUUID() + + setupV16DbWithEventWithExternalCredentialIds(eventId) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + val eventJson = MigrationTestingTools + .retrieveCursorWithEventById(db, eventId) + .getStringWithColumnName("eventJson")!! + + // Verify the field appears only once + val firstIndex = eventJson.indexOf("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\"") + val lastIndex = eventJson.lastIndexOf("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\"") + assertThat(firstIndex).isEqualTo(lastIndex) + assertThat(eventJson).contains(EXTERNAL_CREDENTIAL_IDS_JSON_FIELD) + + db.close() + } + + @Test + fun `validate non-ENROLMENT_V4 events are not modified`() { + val eventId = randomUUID() + + setupV16DbWithNonEnrolmentEvent(eventId) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + val eventJson = MigrationTestingTools + .retrieveCursorWithEventById(db, eventId) + .getStringWithColumnName("eventJson")!! + + assertThat(eventJson).doesNotContain(EXTERNAL_CREDENTIAL_IDS_JSON_KEY) + db.close() + } + + private fun createEnrolmentEvent( + id: String, + addCredentialIds: Boolean, + ) = ContentValues().apply { + val externalCredentialIds = if (addCredentialIds) { + EXTERNAL_CREDENTIAL_IDS_JSON_FIELD + } else { + "" + } + put("id", id) + put("type", "ENROLMENT_V4") + put("createdAt_unixMs", 123) + put("createdAt_isTrustworthy", 0) + put("projectId", "9WNCAbWVNrxttDe5hgwb") + put("scopeId", "2bdc1145") + val unversionedEnrolmentEvent = + """ + { + "id":"$id", + "payload":{ + "createdAt":{ + "ms":1762805893067, + "isTrustworthy":true, + "msSinceBoot":35002538 + }, + "eventVersion":4, + "subjectId":"74639420-8e77-4a40-a452-280f295f147f", + "projectId":"FW1jU2kjy1cV9RWXdosN", + "moduleId":{ + "className":"TokenizableString.Tokenized", + "value":"AV50RNsaMs9jpoHwcXZqir1uB3St0vsexOpixA==" + }, + "attendantId":{ + "className":"TokenizableString.Tokenized", + "value":"AQYk7uBNIkhgOVGR3f/0HTjX/LRk0fKi+g==" + }, + "biometricReferenceIds":[ + "b12815ff-a4bc-4d7f-ae88-608640c7138d" + ], + $externalCredentialIds + "type":"ENROLMENT_V4" + }, + "type":"ENROLMENT_V4", + "scopeId":"c33023a1-d335-4310-b088-81575daafea3", + "projectId":"FW1jU2kjy1cV9RWXdosN" + } + """.trimIndent() + put("eventJson", unversionedEnrolmentEvent) + } + + private fun createNonEnrolmentEvent(id: String) = ContentValues().apply { + put("id", id) + put("type", "INTENT_PARSING") + put("createdAt_unixMs", 123) + put("createdAt_isTrustworthy", 0) + put("projectId", "9WNCAbWVNrxttDe5hgwb") + put("scopeId", "2bdc1145") + put( + "eventJson", + """ + { + "id":"$id", + "projectId":"9WNCAbWVNrxttDe5hgwb", + "sessionId":"2bdc1145-cbec-4e6a-ac8a-61c1e5b53bb4", + "payload":{ + "createdAt":{ + "ms":1706534485916, + "isTrustworthy":false, + "msSinceBoot":null + }, + "eventVersion":2, + "integration":"STANDARD", + "type":"INTENT_PARSING", + "endedAt":{ + "ms":1706534528165, + "isTrustworthy":false, + "msSinceBoot":null + } + }, + "type":"INTENT_PARSING" + } + """.trimIndent(), + ) + } + + private fun setupV16DbWithEvent( + vararg eventId: String, + close: Boolean = true, + ): SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 16).apply { + eventId.forEach { id -> + val event = createEnrolmentEvent(id, addCredentialIds = false) + this.insert("DbEvent", SQLiteDatabase.CONFLICT_NONE, event) + } + if (close) { + close() + } + } + + private fun setupV16DbWithEventWithExternalCredentialIds( + eventId: String, + close: Boolean = true, + ): SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 16).apply { + val event = createEnrolmentEvent(eventId, addCredentialIds = true) + this.insert("DbEvent", SQLiteDatabase.CONFLICT_NONE, event) + if (close) { + close() + } + } + + private fun setupV16DbWithNonEnrolmentEvent( + eventId: String, + close: Boolean = true, + ): SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 16).apply { + val event = createNonEnrolmentEvent(eventId) + this.insert("DbEvent", SQLiteDatabase.CONFLICT_NONE, event) + if (close) { + close() + } + } + + companion object { + private const val TEST_DB = "test" + private const val EXTERNAL_CREDENTIAL_IDS_JSON_KEY = "externalCredentialIds" + private const val EXTERNAL_CREDENTIAL_IDS_JSON_FIELD = + "\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\": [\"74639420-8e77-4a40-a452-280f295f147f\"]\"," + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt index 13f3e1e9f1..feafb7444b 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt @@ -47,7 +47,7 @@ class EventMigrationTest { } close() } - val db = helper.runMigrationsAndValidate(TEST_DB, 16, true, *ALL_MIGRATIONS) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, *ALL_MIGRATIONS) db.query("SELECT * FROM $TABLE_NAME").use { cursor -> while (cursor.moveToNext()) { val eventJson = cursor.getStringWithColumnName("eventJson")!! @@ -113,6 +113,7 @@ class EventMigrationTest { EventMigration13to14(), EventMigration14to15(), EventMigration15to16(), + EventMigration16to17(), ) val tokenizeSerializationModule = SimpleModule().apply { addSerializer(TokenizableString::class.java, TokenizationClassNameSerializer())