From 77f71973a761daaea1da67af8248ba6d2e3c13aa Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Fri, 9 Oct 2026 10:07:17 +0200 Subject: [PATCH] QA-002 Room-Tests --- .../data/dao/BlockAndCheckItemDaoTest.kt | 259 ++++++++++++++++++ .../wunddoku/data/dao/DocumentationDaoTest.kt | 222 +++++++++++++++ .../data/dao/DocumentationRelationsTest.kt | 229 ++++++++++++++++ .../wunddoku/data/dao/PatientDaoTest.kt | 232 ++++++++++++++++ .../data/dao/PhotoAndSignatureDaoTest.kt | 168 ++++++++++++ .../wunddoku/data/dao/SearchQueryTest.kt | 229 ++++++++++++++++ .../wunddoku/data/dao/WoundCarerDaoTest.kt | 128 +++++++++ .../data/database/DatabaseTransactionTest.kt | 222 +++++++++++++++ .../medapp/wunddoku/testutil/EntityFactory.kt | 154 +++++++++++ .../medapp/wunddoku/testutil/RoomTestBase.kt | 107 ++++++++ 10 files changed, 1950 insertions(+) create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/dao/BlockAndCheckItemDaoTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationDaoTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationRelationsTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/dao/PatientDaoTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/dao/PhotoAndSignatureDaoTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/dao/SearchQueryTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/dao/WoundCarerDaoTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/database/DatabaseTransactionTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/testutil/EntityFactory.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/testutil/RoomTestBase.kt diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/dao/BlockAndCheckItemDaoTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/BlockAndCheckItemDaoTest.kt new file mode 100644 index 0000000..cbc8abf --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/BlockAndCheckItemDaoTest.kt @@ -0,0 +1,259 @@ +package de.medapp.wunddoku.data.dao + +import android.database.sqlite.SQLiteConstraintException +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.blockStateEntity +import de.medapp.wunddoku.testutil.checkItemStateEntity +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.runDbTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** DAO-Tests für [BlockStateDao] und [CheckItemStateDao]. */ +class BlockAndCheckItemDaoTest : RoomTestBase() { + + // --- BlockStateDao ------------------------------------------------------------------- + + @Test + fun blockStates_insertAll_und_getById_liefern_alle_felder() = runDbTest { + seedDocumentation() + val block = blockStateEntity(title = "Größe & Tiefe", order = 3, updatedAt = 4_000L) + + blockStateDao.insertAll(listOf(block)) + + assertEquals(block, blockStateDao.getById("b-1")) + assertNull(blockStateDao.getById("unbekannt")) + } + + @Test + fun blockStates_getForDocumentation_sortiert_nach_blockOrder_und_filtert_nach_dokumentation() = runDbTest { + seedDocumentation(id = "d-1") + seedDocumentation(id = "d-2") + blockStateDao.insertAll( + listOf( + blockStateEntity(id = "b-3", documentationId = "d-1", blockDefinitionId = "c", order = 3), + blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "a", order = 1), + blockStateEntity(id = "b-2", documentationId = "d-1", blockDefinitionId = "b", order = 2), + blockStateEntity(id = "b-x", documentationId = "d-2", blockDefinitionId = "a", order = 1), + ) + ) + + assertEquals(listOf("b-1", "b-2", "b-3"), blockStateDao.getForDocumentation("d-1").map { it.id }) + assertEquals(listOf("b-x"), blockStateDao.getForDocumentation("d-2").map { it.id }) + assertTrue(blockStateDao.getForDocumentation("unbekannt").isEmpty()) + } + + @Test + fun blockStates_gleiche_blockdefinition_in_derselben_dokumentation_ist_verboten() = runDbTest { + seedDocumentation() + blockStateDao.insertAll(listOf(blockStateEntity(id = "b-1", blockDefinitionId = "wundart"))) + + expectThrows { + blockStateDao.insertAll(listOf(blockStateEntity(id = "b-2", blockDefinitionId = "wundart"))) + } + + assertEquals(1, countRows("block_states")) + } + + @Test + fun blockStates_gleiche_blockdefinition_in_verschiedenen_dokumentationen_ist_erlaubt() = runDbTest { + seedDocumentation(id = "d-1") + seedDocumentation(id = "d-2") + + blockStateDao.insertAll( + listOf( + blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "wundart"), + blockStateEntity(id = "b-2", documentationId = "d-2", blockDefinitionId = "wundart"), + ) + ) + + assertEquals(2, countRows("block_states")) + } + + @Test + fun blockStates_mit_unbekannter_dokumentation_wirft() = runDbTest { + expectThrows { + blockStateDao.insertAll(listOf(blockStateEntity(documentationId = "gibt-es-nicht"))) + } + + assertEquals(0, countRows("block_states")) + } + + @Test + fun blockStates_insertAll_mit_leerer_liste_ist_unkritisch() = runDbTest { + blockStateDao.insertAll(emptyList()) + + assertEquals(0, countRows("block_states")) + } + + // --- CheckItemStateDao --------------------------------------------------------------- + + private suspend fun seedBlock(id: String = "b-1", documentationId: String = "d-1", definitionId: String = "wundart") { + if (documentationDao.getById(documentationId) == null) seedDocumentation(id = documentationId) + blockStateDao.insertAll(listOf(blockStateEntity(id = id, documentationId = documentationId, blockDefinitionId = definitionId))) + } + + @Test + fun checkItems_insertAll_und_getForBlockState_liefern_alle_felder_inklusive_standardwerten() = runDbTest { + seedBlock() + val plain = checkItemStateEntity(id = "c-1", definitionId = "plain", order = 1) + val full = checkItemStateEntity( + id = "c-2", definitionId = "full", order = 2, checked = true, isFreeText = true, + freeTextValue = "Text", isStaticHint = false, inputSpec = "SPEC", + ) + + checkItemStateDao.insertAll(listOf(plain, full)) + + val loaded = checkItemStateDao.getForBlockState("b-1") + assertEquals(listOf(plain, full), loaded) + assertFalse(loaded[0].checked) + assertFalse(loaded[0].isFreeText) + assertEquals("", loaded[0].freeTextValue) + assertFalse(loaded[0].isStaticHint) + assertEquals("", loaded[0].inputSpec) + } + + @Test + fun checkItems_getForBlockState_sortiert_nach_itemOrder_und_filtert_nach_block() = runDbTest { + seedBlock(id = "b-1", definitionId = "a") + seedBlock(id = "b-2", definitionId = "b") + checkItemStateDao.insertAll( + listOf( + checkItemStateEntity(id = "c-3", blockStateId = "b-1", definitionId = "z", order = 3), + checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "x", order = 1), + checkItemStateEntity(id = "c-2", blockStateId = "b-1", definitionId = "y", order = 2), + checkItemStateEntity(id = "c-x", blockStateId = "b-2", definitionId = "x", order = 1), + ) + ) + + assertEquals(listOf("c-1", "c-2", "c-3"), checkItemStateDao.getForBlockState("b-1").map { it.id }) + assertEquals(listOf("c-x"), checkItemStateDao.getForBlockState("b-2").map { it.id }) + assertTrue(checkItemStateDao.getForBlockState("unbekannt").isEmpty()) + } + + @Test + fun checkItems_setChecked_setzt_und_entfernt_den_haken_ohne_andere_felder_zu_aendern() = runDbTest { + seedBlock() + val item = checkItemStateEntity(freeTextValue = "bleibt", inputSpec = "bleibt auch") + checkItemStateDao.insertAll(listOf(item)) + + checkItemStateDao.setChecked("c-1", true) + assertEquals(item.copy(checked = true), checkItemStateDao.getForBlockState("b-1").single()) + + checkItemStateDao.setChecked("c-1", false) + assertEquals(item, checkItemStateDao.getForBlockState("b-1").single()) + } + + @Test + fun checkItems_setChecked_betrifft_nur_die_angegebene_option() = runDbTest { + seedBlock() + checkItemStateDao.insertAll( + listOf( + checkItemStateEntity(id = "c-1", definitionId = "a", order = 1), + checkItemStateEntity(id = "c-2", definitionId = "b", order = 2), + ) + ) + + checkItemStateDao.setChecked("c-1", true) + + val byId = checkItemStateDao.getForBlockState("b-1").associateBy { it.id } + assertTrue(byId.getValue("c-1").checked) + assertFalse(byId.getValue("c-2").checked) + } + + @Test + fun checkItems_setChecked_und_updateFreeTextValue_mit_unbekannter_id_sind_wirkungslos() = runDbTest { + seedBlock() + val item = checkItemStateEntity() + checkItemStateDao.insertAll(listOf(item)) + + checkItemStateDao.setChecked("unbekannt", true) + checkItemStateDao.updateFreeTextValue("unbekannt", "x") + + assertEquals(listOf(item), checkItemStateDao.getForBlockState("b-1")) + } + + @Test + fun checkItems_updateFreeTextValue_aendert_nur_den_text_und_nicht_den_haken() = runDbTest { + seedBlock() + checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, checked = true))) + + checkItemStateDao.updateFreeTextValue("c-1", "Rötung am Wundrand") + + val loaded = checkItemStateDao.getForBlockState("b-1").single() + assertEquals("Rötung am Wundrand", loaded.freeTextValue) + assertTrue(loaded.checked) + assertTrue(loaded.isFreeText) + } + + @Test + fun checkItems_freitext_laesst_sich_leeren() = runDbTest { + seedBlock() + checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, freeTextValue = "alt"))) + + checkItemStateDao.updateFreeTextValue("c-1", "") + + assertEquals("", checkItemStateDao.getForBlockState("b-1").single().freeTextValue) + } + + @Test + fun checkItems_sonderzeichen_und_sql_fragmente_werden_als_reiner_text_gespeichert() = runDbTest { + seedBlock() + checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true))) + val text = "O'Brien \"Test\" ; DROP TABLE patients; -- Größe 3,5 × 2 cm\nzweite Zeile\t😀 100% _x_" + + checkItemStateDao.updateFreeTextValue("c-1", text) + + assertEquals(text, checkItemStateDao.getForBlockState("b-1").single().freeTextValue) + assertEquals(1, countRows("patients")) // Tabelle existiert weiterhin, der Seed-Patient ist unangetastet + } + + @Test + fun checkItems_inputSpec_mit_steuerzeichen_der_codierung_bleibt_verlustfrei_erhalten() = runDbTest { + // InputSpecCodec nutzt \u001E, \u001F und \u001D als Trennzeichen. + seedBlock() + val spec = "tpl\u001EN\u001Flabel\u001F3\u001Dx\u001Ey" + checkItemStateDao.insertAll(listOf(checkItemStateEntity(inputSpec = spec))) + + assertEquals(spec, checkItemStateDao.getForBlockState("b-1").single().inputSpec) + } + + @Test + fun checkItems_gleiche_optionsdefinition_im_selben_block_ist_verboten() = runDbTest { + seedBlock() + checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-1", definitionId = "dup"))) + + expectThrows { + checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-2", definitionId = "dup"))) + } + + assertEquals(1, countRows("check_item_states")) + } + + @Test + fun checkItems_gleiche_optionsdefinition_in_verschiedenen_bloecken_ist_erlaubt() = runDbTest { + seedBlock(id = "b-1", definitionId = "a") + seedBlock(id = "b-2", definitionId = "b") + + checkItemStateDao.insertAll( + listOf( + checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "same"), + checkItemStateEntity(id = "c-2", blockStateId = "b-2", definitionId = "same"), + ) + ) + + assertEquals(2, countRows("check_item_states")) + } + + @Test + fun checkItems_mit_unbekanntem_block_wirft() = runDbTest { + expectThrows { + checkItemStateDao.insertAll(listOf(checkItemStateEntity(blockStateId = "gibt-es-nicht"))) + } + + assertEquals(0, countRows("check_item_states")) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationDaoTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationDaoTest.kt new file mode 100644 index 0000000..6321d09 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationDaoTest.kt @@ -0,0 +1,222 @@ +package de.medapp.wunddoku.data.dao + +import android.database.sqlite.SQLiteConstraintException +import de.medapp.wunddoku.domain.model.DocumentationStatus +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.awaitMatching +import de.medapp.wunddoku.testutil.documentationEntity +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.runDbTest +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** DAO-Tests für [DocumentationDao] (ohne Suche und Relationen, s. SearchQueryTest / DocumentationRelationsTest). */ +class DocumentationDaoTest : RoomTestBase() { + + // --- insert / getById ---------------------------------------------------------------- + + @Test + fun insert_und_getById_liefern_alle_felder_inklusive_snapshots() = runDbTest { + seedPatient() + val entity = documentationEntity( + status = DocumentationStatus.COMPLETED, + createdAt = 1_000L, + updatedAt = 2_000L, + pdfLastGeneratedAt = 3_000L, + catalogVersion = 3, + firstNameSnapshot = "Erika", + lastNameSnapshot = "Musterfrau", + birthDateSnapshot = "1975-12-01", + streetSnapshot = "Hauptstraße 5", + postalCodeSnapshot = "54321", + citySnapshot = "Beispielstadt", + ) + + documentationDao.insert(entity) + + assertEquals(entity, documentationDao.getById("d-1")) + } + + @Test + fun nullable_felder_bleiben_null() = runDbTest { + seedDocumentation() + + val loaded = documentationDao.getById("d-1")!! + + assertNull(loaded.continuedFromDocumentationId) + assertNull(loaded.pdfLastGeneratedAt) + } + + @Test + fun getById_liefert_null_fuer_unbekannte_id() = runDbTest { + assertNull(documentationDao.getById("unbekannt")) + } + + @Test + fun status_wird_als_enum_name_gespeichert_und_vollstaendig_zurueckgelesen() = runDbTest { + seedPatient() + DocumentationStatus.values().forEachIndexed { index, status -> + documentationDao.insert(documentationEntity(id = "d-$index", status = status)) + } + + DocumentationStatus.values().forEachIndexed { index, status -> + assertEquals(status, documentationDao.getById("d-$index")!!.status) + assertEquals(status.name, queryString("SELECT status FROM documentations WHERE id = 'd-$index'")) + } + } + + // --- Foreign Keys -------------------------------------------------------------------- + + @Test + fun insert_mit_unbekanntem_patienten_wirft() = runDbTest { + expectThrows { + documentationDao.insert(documentationEntity(patientId = "gibt-es-nicht")) + } + + assertEquals(0, countRows("documentations")) + } + + @Test + fun insert_mit_unbekannter_fortsetzungsquelle_wirft() = runDbTest { + seedPatient() + + expectThrows { + documentationDao.insert(documentationEntity(continuedFromDocumentationId = "gibt-es-nicht")) + } + + assertEquals(0, countRows("documentations")) + } + + @Test + fun fortsetzungsquelle_wird_beim_loeschen_der_quelle_auf_null_gesetzt() = runDbTest { + val source = seedDocumentation(id = "d-source") + documentationDao.insert(documentationEntity(id = "d-copy", continuedFromDocumentationId = "d-source")) + assertEquals("d-source", documentationDao.getById("d-copy")!!.continuedFromDocumentationId) + + documentationDao.delete(source) + + assertNull(documentationDao.getById("d-source")) + val copy = documentationDao.getById("d-copy") + assertNotNull("Die Fortsetzung darf nicht mitgelöscht werden", copy) + assertNull(copy!!.continuedFromDocumentationId) + } + + // --- update / delete ----------------------------------------------------------------- + + @Test + fun update_aendert_nur_die_uebergebene_dokumentation() = runDbTest { + val first = seedDocumentation(id = "d-1") + val second = seedDocumentation(id = "d-2") + + documentationDao.update(first.copy(status = DocumentationStatus.COMPLETED, updatedAt = 9_000L, pdfLastGeneratedAt = 9_500L)) + + val changed = documentationDao.getById("d-1")!! + assertEquals(DocumentationStatus.COMPLETED, changed.status) + assertEquals(9_000L, changed.updatedAt) + assertEquals(9_500L, changed.pdfLastGeneratedAt) + assertEquals(second, documentationDao.getById("d-2")) + } + + @Test + fun update_einer_unbekannten_id_legt_nichts_an() = runDbTest { + seedPatient() + + documentationDao.update(documentationEntity(id = "geist")) + + assertNull(documentationDao.getById("geist")) + } + + @Test + fun delete_entfernt_nur_die_uebergebene_dokumentation_und_laesst_den_patienten_bestehen() = runDbTest { + val first = seedDocumentation(id = "d-1") + seedDocumentation(id = "d-2") + + documentationDao.delete(first) + + assertNull(documentationDao.getById("d-1")) + assertNotNull(documentationDao.getById("d-2")) + assertNotNull(patientDao.getById("p-1")) + } + + // --- observeAll / countForPatient ---------------------------------------------------- + + @Test + fun observeAll_sortiert_nach_erstelldatum_absteigend() = runDbTest { + seedDocumentation(id = "d-alt", createdAt = 1_000L) + seedDocumentation(id = "d-neu", createdAt = 3_000L) + seedDocumentation(id = "d-mitte", createdAt = 2_000L) + + val ids = documentationDao.observeAll().first().map { it.id } + + assertEquals(listOf("d-neu", "d-mitte", "d-alt"), ids) + } + + @Test + fun observeAll_liefert_leere_liste_ohne_dokumentationen() = runDbTest { + assertTrue(documentationDao.observeAll().first().isEmpty()) + } + + @Test + fun observeAll_aktualisiert_sich_bei_insert_update_und_delete() = runDbTest { + seedPatient() + + val afterInsert = async { documentationDao.observeAll().awaitMatching { it.size == 1 } } + val entity = documentationEntity(id = "d-1") + documentationDao.insert(entity) + assertEquals("d-1", afterInsert.await().single().id) + + val afterUpdate = async { + documentationDao.observeAll().awaitMatching { list -> list.singleOrNull()?.status == DocumentationStatus.COMPLETED } + } + documentationDao.update(entity.copy(status = DocumentationStatus.COMPLETED)) + afterUpdate.await() + + val afterDelete = async { documentationDao.observeAll().awaitMatching { it.isEmpty() } } + documentationDao.delete(entity) + assertTrue(afterDelete.await().isEmpty()) + } + + @Test + fun countForPatient_zaehlt_nur_dokumentationen_des_jeweiligen_patienten() = runDbTest { + seedDocumentation(id = "d-1", patientId = "p-1") + seedDocumentation(id = "d-2", patientId = "p-1") + seedDocumentation(id = "d-3", patientId = "p-2") + + assertEquals(2, documentationDao.countForPatient("p-1")) + assertEquals(1, documentationDao.countForPatient("p-2")) + assertEquals(0, documentationDao.countForPatient("unbekannt")) + } + + // --- getLatestForPatient ------------------------------------------------------------- + + @Test + fun getLatestForPatient_liefert_die_zuletzt_erstellte_dokumentation_des_patienten() = runDbTest { + seedDocumentation(id = "d-alt", patientId = "p-1", createdAt = 1_000L) + seedDocumentation(id = "d-neu", patientId = "p-1", createdAt = 3_000L) + seedDocumentation(id = "d-mitte", patientId = "p-1", createdAt = 2_000L) + + assertEquals("d-neu", documentationDao.getLatestForPatient("p-1")?.documentation?.id) + } + + @Test + fun getLatestForPatient_ignoriert_dokumentationen_anderer_patienten() = runDbTest { + seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 1_000L) + seedDocumentation(id = "d-2", patientId = "p-2", createdAt = 9_000L) + + assertEquals("d-1", documentationDao.getLatestForPatient("p-1")?.documentation?.id) + assertEquals("d-2", documentationDao.getLatestForPatient("p-2")?.documentation?.id) + } + + @Test + fun getLatestForPatient_liefert_null_ohne_dokumentation() = runDbTest { + seedPatient() + + assertNull(documentationDao.getLatestForPatient("p-1")) + assertNull(documentationDao.getLatestForPatient("unbekannt")) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationRelationsTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationRelationsTest.kt new file mode 100644 index 0000000..18e2df6 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/DocumentationRelationsTest.kt @@ -0,0 +1,229 @@ +package de.medapp.wunddoku.data.dao + +import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.blockStateEntity +import de.medapp.wunddoku.testutil.checkItemStateEntity +import de.medapp.wunddoku.testutil.photoEntity +import de.medapp.wunddoku.testutil.runDbTest +import de.medapp.wunddoku.testutil.signatureEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Relationstests (QA-002): [DocumentationWithDetails] (Dokumentation -> Blöcke -> Optionen, + * Fotos, Signaturen) sowie Cascade-/Restrict-Verhalten der Foreign Keys. + */ +class DocumentationRelationsTest : RoomTestBase() { + + /** + * Dokumentation [docId] mit 2 Blöcken ("-b1" order 2, "-b2" order 1), + * 3 Optionen (b1: c1, c2 / b2: c3), 2 Fotos und 2 Signaturen. + */ + private suspend fun seedWithChildren(docId: String, createdAt: Long, prefix: String) { + seedDocumentation(id = docId, patientId = "p-1", createdAt = createdAt) + blockStateDao.insertAll( + listOf( + blockStateEntity(id = "$prefix-b1", documentationId = docId, blockDefinitionId = "wundart", order = 2), + blockStateEntity(id = "$prefix-b2", documentationId = docId, blockDefinitionId = "groesse", order = 1), + ) + ) + checkItemStateDao.insertAll( + listOf( + checkItemStateEntity(id = "$prefix-c1", blockStateId = "$prefix-b1", definitionId = "a", order = 1), + checkItemStateEntity(id = "$prefix-c2", blockStateId = "$prefix-b1", definitionId = "b", order = 2), + checkItemStateEntity(id = "$prefix-c3", blockStateId = "$prefix-b2", definitionId = "a", order = 1), + ) + ) + photoDao.insert(photoEntity(id = "$prefix-ph1", documentationId = docId, orderIndex = 0)) + photoDao.insert(photoEntity(id = "$prefix-ph2", documentationId = docId, orderIndex = 1)) + signatureDao.insert(signatureEntity(id = "$prefix-s1", documentationId = docId, orderIndex = 0)) + signatureDao.insert(signatureEntity(id = "$prefix-s2", documentationId = docId, orderIndex = 1)) + } + + private fun DocumentationWithDetails.itemIdsByBlock(): Map> = + blocks.associate { it.blockState.id to it.checkItems.map { item -> item.id }.toSet() } + + // --- Laden --------------------------------------------------------------------------- + + @Test + fun getFullDocumentation_liefert_dokumentation_mit_allen_kindern() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + + val full = documentationDao.getFullDocumentation("d-a")!! + + assertEquals("d-a", full.documentation.id) + assertEquals(setOf("a-b1", "a-b2"), full.blocks.map { it.blockState.id }.toSet()) + assertEquals(setOf("a-ph1", "a-ph2"), full.photos.map { it.id }.toSet()) + assertEquals(setOf("a-s1", "a-s2"), full.signatures.map { it.id }.toSet()) + } + + @Test + fun jede_option_haengt_am_richtigen_block() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + + val full = documentationDao.getFullDocumentation("d-a")!! + + assertEquals( + mapOf("a-b1" to setOf("a-c1", "a-c2"), "a-b2" to setOf("a-c3")), + full.itemIdsByBlock(), + ) + } + + @Test + fun relation_enthaelt_die_vollstaendigen_entities_nicht_nur_ids() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + + val full = documentationDao.getFullDocumentation("d-a")!! + + assertEquals(documentationDao.getById("d-a"), full.documentation) + assertEquals(blockStateDao.getById("a-b1"), full.blocks.single { it.blockState.id == "a-b1" }.blockState) + assertEquals(photoDao.getById("a-ph1"), full.photos.single { it.id == "a-ph1" }) + assertEquals(signatureDao.getById("a-s1"), full.signatures.single { it.id == "a-s1" }) + assertEquals( + checkItemStateDao.getForBlockState("a-b1"), + full.blocks.single { it.blockState.id == "a-b1" }.checkItems.sortedBy { it.itemOrder }, + ) + } + + @Test + fun relation_vermischt_keine_daten_verschiedener_dokumentationen() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + seedWithChildren("d-b", 2_000L, "b") + + val a = documentationDao.getFullDocumentation("d-a")!! + val b = documentationDao.getFullDocumentation("d-b")!! + + assertTrue(a.blocks.all { it.blockState.documentationId == "d-a" }) + assertTrue(a.photos.all { it.documentationId == "d-a" }) + assertTrue(a.signatures.all { it.documentationId == "d-a" }) + assertEquals(setOf("a-c1", "a-c2", "a-c3"), a.itemIdsByBlock().values.flatten().toSet()) + assertEquals(setOf("b-c1", "b-c2", "b-c3"), b.itemIdsByBlock().values.flatten().toSet()) + } + + @Test + fun dokumentation_ohne_kinder_liefert_leere_listen_statt_null() = runDbTest { + seedDocumentation(id = "d-leer") + + val full = documentationDao.getFullDocumentation("d-leer")!! + + assertTrue(full.blocks.isEmpty()) + assertTrue(full.photos.isEmpty()) + assertTrue(full.signatures.isEmpty()) + } + + @Test + fun block_ohne_optionen_liefert_leere_optionsliste() = runDbTest { + seedDocumentation() + blockStateDao.insertAll(listOf(blockStateEntity())) + + val block = documentationDao.getFullDocumentation("d-1")!!.blocks.single() + + assertTrue(block.checkItems.isEmpty()) + } + + @Test + fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest { + assertNull(documentationDao.getFullDocumentation("unbekannt")) + } + + @Test + fun getLatestForPatient_laedt_auch_die_kinder_der_neuesten_dokumentation() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + seedWithChildren("d-b", 2_000L, "b") + + val latest = documentationDao.getLatestForPatient("p-1")!! + + assertEquals("d-b", latest.documentation.id) + assertEquals(setOf("b-b1", "b-b2"), latest.blocks.map { it.blockState.id }.toSet()) + assertEquals(setOf("b-c1", "b-c2", "b-c3"), latest.itemIdsByBlock().values.flatten().toSet()) + assertEquals(2, latest.photos.size) + assertEquals(2, latest.signatures.size) + } + + @Test + fun grosse_dokumentation_wird_vollstaendig_und_korrekt_zugeordnet_geladen() = runDbTest { + seedDocumentation() + val blocks = (1..25).map { b -> + blockStateEntity(id = "b-$b", blockDefinitionId = "block_$b", title = "Block $b", order = b) + } + blockStateDao.insertAll(blocks) + val items = (1..25).flatMap { b -> + (1..20).map { i -> + checkItemStateEntity(id = "c-$b-$i", blockStateId = "b-$b", definitionId = "item_$i", order = i) + } + } + checkItemStateDao.insertAll(items) + + val full = documentationDao.getFullDocumentation("d-1")!! + + assertEquals(25, full.blocks.size) + assertEquals(500, full.blocks.sumOf { it.checkItems.size }) + for (block in full.blocks) { + assertEquals(20, block.checkItems.size) + assertTrue(block.checkItems.all { it.blockStateId == block.blockState.id }) + } + } + + // --- Cascade / Restrict -------------------------------------------------------------- + + @Test + fun foreign_keys_sind_in_der_testdatenbank_aktiviert() = runDbTest { + assertEquals(1, queryInt("PRAGMA foreign_keys")) + } + + @Test + fun dokumentation_loeschen_entfernt_bloecke_optionen_fotos_und_signaturen_per_cascade() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + + documentationDao.delete(documentationDao.getById("d-a")!!) + + assertEquals(0, countRows("documentations")) + assertEquals(0, countRows("block_states")) + assertEquals(0, countRows("check_item_states")) + assertEquals(0, countRows("photos")) + assertEquals(0, countRows("signatures")) + } + + @Test + fun dokumentation_loeschen_laesst_patient_und_andere_dokumentationen_unangetastet() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + seedWithChildren("d-b", 2_000L, "b") + + documentationDao.delete(documentationDao.getById("d-a")!!) + + assertNotNull(patientDao.getById("p-1")) + val remaining = documentationDao.getFullDocumentation("d-b")!! + assertEquals(2, remaining.blocks.size) + assertEquals(3, remaining.itemIdsByBlock().values.sumOf { it.size }) + assertEquals(2, remaining.photos.size) + assertEquals(2, remaining.signatures.size) + assertEquals(2, countRows("block_states")) + assertEquals(3, countRows("check_item_states")) + } + + @Test + fun block_loeschen_entfernt_seine_optionen_per_cascade_aber_nicht_die_anderer_bloecke() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + + execSql("DELETE FROM block_states WHERE id = 'a-b1'") + + assertEquals(listOf("a-c3"), checkItemStateDao.getForBlockState("a-b2").map { it.id }) + assertTrue(checkItemStateDao.getForBlockState("a-b1").isEmpty()) + assertEquals(1, countRows("check_item_states")) + } + + @Test + fun patient_mit_dokumentation_kann_nicht_geloescht_werden_und_kinder_bleiben_erhalten() = runDbTest { + seedWithChildren("d-a", 1_000L, "a") + + val failed = runCatching { patientDao.delete(patientDao.getById("p-1")!!) } + + assertTrue(failed.isFailure) + assertNotNull(documentationDao.getFullDocumentation("d-a")) + assertEquals(3, countRows("check_item_states")) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/dao/PatientDaoTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/PatientDaoTest.kt new file mode 100644 index 0000000..71be2ae --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/PatientDaoTest.kt @@ -0,0 +1,232 @@ +package de.medapp.wunddoku.data.dao + +import android.database.sqlite.SQLiteConstraintException +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.awaitMatching +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.patientEntity +import de.medapp.wunddoku.testutil.runDbTest +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** DAO-Tests für [PatientDao]. */ +class PatientDaoTest : RoomTestBase() { + + // --- insert / getById ---------------------------------------------------------------- + + @Test + fun insert_und_getById_liefern_alle_felder_zurueck() = runDbTest { + val entity = patientEntity(phone2 = "0987 654321", phone3 = "0555 123") + + patientDao.insert(entity) + + assertEquals(entity, patientDao.getById("p-1")) + } + + @Test + fun optionale_telefonnummern_bleiben_null() = runDbTest { + patientDao.insert(patientEntity(phone2 = null, phone3 = null)) + + val loaded = patientDao.getById("p-1")!! + + assertNull(loaded.phone2) + assertNull(loaded.phone3) + } + + @Test + fun umlaute_und_sonderzeichen_bleiben_unveraendert() = runDbTest { + val entity = patientEntity( + firstName = "Jörg-Åke", lastName = "O'Brien-Müller", street = "Straße \"des\" 17. Juni 1/2", city = "Köln-Ehrenfeld", + ) + + patientDao.insert(entity) + + assertEquals(entity, patientDao.getById("p-1")) + } + + @Test + fun getById_liefert_null_fuer_unbekannte_id() = runDbTest { + assertNull(patientDao.getById("unbekannt")) + } + + @Test + fun insert_mit_vorhandener_id_wirft_und_laesst_den_bestehenden_datensatz_unveraendert() = runDbTest { + val original = patientEntity(lastName = "Original") + patientDao.insert(original) + + expectThrows { patientDao.insert(patientEntity(lastName = "Duplikat")) } + + assertEquals(original, patientDao.getById("p-1")) + } + + @Test + fun doppelte_stammdaten_mit_unterschiedlicher_id_sind_auf_db_ebene_erlaubt() = runDbTest { + // Duplikatschutz liegt bewusst in der Anwendung (findExisting), nicht in einem UNIQUE-Index. + patientDao.insert(patientEntity(id = "p-1")) + patientDao.insert(patientEntity(id = "p-2")) + + assertEquals(2, countRows("patients")) + } + + // --- update / delete ----------------------------------------------------------------- + + @Test + fun update_ueberschreibt_die_felder_des_datensatzes() = runDbTest { + patientDao.insert(patientEntity()) + val changed = patientEntity(lastName = "Musterfrau", city = "Neustadt", phone2 = "0111", updatedAt = 2_000L) + + patientDao.update(changed) + + assertEquals(changed, patientDao.getById("p-1")) + } + + @Test + fun update_einer_unbekannten_id_legt_keinen_datensatz_an() = runDbTest { + patientDao.update(patientEntity(id = "geist")) + + assertNull(patientDao.getById("geist")) + assertEquals(0, countRows("patients")) + } + + @Test + fun delete_entfernt_nur_den_uebergebenen_patienten() = runDbTest { + patientDao.insert(patientEntity(id = "p-1")) + patientDao.insert(patientEntity(id = "p-2")) + + patientDao.delete(patientEntity(id = "p-1")) + + assertNull(patientDao.getById("p-1")) + assertNotNull(patientDao.getById("p-2")) + } + + @Test + fun delete_eines_unbekannten_patienten_ist_wirkungslos() = runDbTest { + patientDao.insert(patientEntity()) + + patientDao.delete(patientEntity(id = "unbekannt")) + + assertEquals(1, countRows("patients")) + } + + @Test + fun delete_wird_durch_vorhandene_dokumentation_verhindert_restrict() = runDbTest { + seedDocumentation() + + expectThrows { patientDao.delete(patientEntity()) } + + assertNotNull(patientDao.getById("p-1")) + assertEquals(1, countRows("documentations")) + } + + // --- findExisting -------------------------------------------------------------------- + + @Test + fun findExisting_trifft_bei_gleichem_vornamen_nachnamen_und_geburtsdatum() = runDbTest { + patientDao.insert(patientEntity()) + + assertEquals("p-1", patientDao.findExisting("Max", "Mustermann", "1980-03-14")?.id) + } + + @Test + fun findExisting_trifft_nicht_bei_abweichendem_feld() = runDbTest { + patientDao.insert(patientEntity()) + + assertNull(patientDao.findExisting("Moritz", "Mustermann", "1980-03-14")) + assertNull(patientDao.findExisting("Max", "Musterfrau", "1980-03-14")) + assertNull(patientDao.findExisting("Max", "Mustermann", "1980-03-15")) + } + + @Test + fun findExisting_ist_ein_exakter_abgleich_und_unterscheidet_gross_und_kleinschreibung() = runDbTest { + // Dokumentiert das aktuelle Verhalten (SQL "="): "max" findet "Max" NICHT. + patientDao.insert(patientEntity()) + + assertNull(patientDao.findExisting("max", "mustermann", "1980-03-14")) + } + + @Test + fun findExisting_liefert_bei_mehreren_treffern_genau_einen() = runDbTest { + patientDao.insert(patientEntity(id = "p-1")) + patientDao.insert(patientEntity(id = "p-2")) + + val found = patientDao.findExisting("Max", "Mustermann", "1980-03-14") + + assertTrue(found?.id == "p-1" || found?.id == "p-2") + } + + // --- hasDocumentations --------------------------------------------------------------- + + @Test + fun hasDocumentations_ist_false_ohne_und_true_mit_dokumentation() = runDbTest { + seedPatient("p-1") + seedPatient("p-2", lastName = "Musterfrau") + seedDocumentation(id = "d-1", patientId = "p-1") + + assertTrue(patientDao.hasDocumentations("p-1")) + assertFalse(patientDao.hasDocumentations("p-2")) + assertFalse(patientDao.hasDocumentations("unbekannt")) + } + + // --- observeAllWithLastDocumentation ------------------------------------------------- + + @Test + fun observeAllWithLastDocumentation_sortiert_nach_nachname_dann_vorname() = runDbTest { + seedPatient("p-1", firstName = "Max", lastName = "Müller") + seedPatient("p-2", firstName = "Anna", lastName = "Müller") + seedPatient("p-3", firstName = "Zoe", lastName = "Adler") + + val result = patientDao.observeAllWithLastDocumentation().first() + + assertEquals(listOf("p-3", "p-2", "p-1"), result.map { it.patient.id }) + } + + @Test + fun lastDocumentationAt_ist_das_maximale_erstelldatum_unabhaengig_von_der_einfuegereihenfolge() = runDbTest { + seedPatient("p-1") + seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 3_000L) + seedDocumentation(id = "d-2", patientId = "p-1", createdAt = 1_000L) + seedDocumentation(id = "d-3", patientId = "p-1", createdAt = 2_000L) + + val result = patientDao.observeAllWithLastDocumentation().first().single() + + assertEquals(3_000L, result.lastDocumentationAt) + } + + @Test + fun lastDocumentationAt_ist_null_ohne_dokumentation_und_beruecksichtigt_nur_eigene_dokumentationen() = runDbTest { + seedPatient("p-1") + seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau") + seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 5_000L) + + val byId = patientDao.observeAllWithLastDocumentation().first().associateBy { it.patient.id } + + assertEquals(5_000L, byId.getValue("p-1").lastDocumentationAt) + assertNull(byId.getValue("p-2").lastDocumentationAt) + } + + @Test + fun observeAllWithLastDocumentation_liefert_leere_liste_ohne_patienten() = runDbTest { + assertTrue(patientDao.observeAllWithLastDocumentation().first().isEmpty()) + } + + @Test + fun observeAllWithLastDocumentation_aktualisiert_sich_bei_aenderungen() = runDbTest { + seedPatient("p-1") + val waiting = async { + patientDao.observeAllWithLastDocumentation().awaitMatching { list -> + list.size == 2 && list.any { it.patient.id == "p-1" && it.lastDocumentationAt == 9_000L } + } + } + + seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau") + seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 9_000L) + + assertEquals(2, waiting.await().size) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/dao/PhotoAndSignatureDaoTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/PhotoAndSignatureDaoTest.kt new file mode 100644 index 0000000..363f196 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/PhotoAndSignatureDaoTest.kt @@ -0,0 +1,168 @@ +package de.medapp.wunddoku.data.dao + +import android.database.sqlite.SQLiteConstraintException +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.photoEntity +import de.medapp.wunddoku.testutil.runDbTest +import de.medapp.wunddoku.testutil.signatureEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** DAO-Tests für [PhotoDao] und [SignatureDao] (beide nach demselben Muster aufgebaut). */ +class PhotoAndSignatureDaoTest : RoomTestBase() { + + // --- PhotoDao ------------------------------------------------------------------------ + + @Test + fun photo_insert_und_getById_liefern_alle_felder() = runDbTest { + seedDocumentation() + val photo = photoEntity(fileName = "wunde_2026-08-23.jpg", orderIndex = 2, createdAt = 5_000L) + + photoDao.insert(photo) + + assertEquals(photo, photoDao.getById("ph-1")) + assertNull(photoDao.getById("unbekannt")) + } + + @Test + fun photo_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest { + seedDocumentation(id = "d-1") + seedDocumentation(id = "d-2") + photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-1", orderIndex = 2)) + photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0)) + photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 1)) + photoDao.insert(photoEntity(id = "ph-x", documentationId = "d-2", orderIndex = 0)) + + assertEquals(listOf("ph-1", "ph-2", "ph-3"), photoDao.getForDocumentation("d-1").map { it.id }) + assertEquals(listOf("ph-x"), photoDao.getForDocumentation("d-2").map { it.id }) + assertTrue(photoDao.getForDocumentation("unbekannt").isEmpty()) + } + + @Test + fun photo_getMaxOrderIndex_ist_minus_eins_ohne_fotos_sonst_der_hoechste_wert() = runDbTest { + seedDocumentation(id = "d-1") + seedDocumentation(id = "d-2") + assertEquals(-1, photoDao.getMaxOrderIndex("d-1")) + + photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0)) + photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 4)) + photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-2", orderIndex = 9)) + + assertEquals(4, photoDao.getMaxOrderIndex("d-1")) + assertEquals(9, photoDao.getMaxOrderIndex("d-2")) + assertEquals(-1, photoDao.getMaxOrderIndex("unbekannt")) + } + + @Test + fun photo_getMaxOrderIndex_bleibt_nach_dem_loeschen_eines_mittleren_fotos_korrekt() = runDbTest { + seedDocumentation() + photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0)) + photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1)) + photoDao.insert(photoEntity(id = "ph-3", orderIndex = 2)) + + photoDao.delete(photoEntity(id = "ph-2", orderIndex = 1)) + + assertEquals(2, photoDao.getMaxOrderIndex("d-1")) + assertEquals(listOf(0, 2), photoDao.getForDocumentation("d-1").map { it.orderIndex }) + } + + @Test + fun photo_delete_entfernt_nur_das_angegebene_foto() = runDbTest { + seedDocumentation() + photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0)) + photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1)) + + photoDao.delete(photoEntity(id = "ph-1")) + + assertNull(photoDao.getById("ph-1")) + assertEquals("ph-2", photoDao.getById("ph-2")?.id) + } + + @Test + fun photo_mit_gleichem_orderIndex_ist_erlaubt_kein_unique_index() = runDbTest { + seedDocumentation() + + photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0)) + photoDao.insert(photoEntity(id = "ph-2", orderIndex = 0)) + + assertEquals(2, photoDao.getForDocumentation("d-1").size) + } + + @Test + fun photo_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest { + seedDocumentation() + photoDao.insert(photoEntity(id = "ph-1")) + + expectThrows { photoDao.insert(photoEntity(id = "ph-1", fileName = "andere.jpg")) } + expectThrows { photoDao.insert(photoEntity(id = "ph-2", documentationId = "gibt-es-nicht")) } + + assertEquals(1, countRows("photos")) + } + + // --- SignatureDao -------------------------------------------------------------------- + + @Test + fun signature_insert_und_getById_liefern_alle_felder() = runDbTest { + seedDocumentation() + val signature = signatureEntity(signerName = "Dr. Åsa Müller-Öz", fileName = "sig_1.png", orderIndex = 1, createdAt = 7_000L) + + signatureDao.insert(signature) + + assertEquals(signature, signatureDao.getById("s-1")) + assertNull(signatureDao.getById("unbekannt")) + } + + @Test + fun signature_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest { + seedDocumentation(id = "d-1") + seedDocumentation(id = "d-2") + signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-1", orderIndex = 2)) + signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0)) + signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 1)) + signatureDao.insert(signatureEntity(id = "s-x", documentationId = "d-2", orderIndex = 0)) + + assertEquals(listOf("s-1", "s-2", "s-3"), signatureDao.getForDocumentation("d-1").map { it.id }) + assertEquals(listOf("s-x"), signatureDao.getForDocumentation("d-2").map { it.id }) + assertTrue(signatureDao.getForDocumentation("unbekannt").isEmpty()) + } + + @Test + fun signature_getMaxOrderIndex_ist_minus_eins_ohne_signaturen_sonst_der_hoechste_wert() = runDbTest { + seedDocumentation(id = "d-1") + seedDocumentation(id = "d-2") + assertEquals(-1, signatureDao.getMaxOrderIndex("d-1")) + + signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0)) + signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 3)) + signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-2", orderIndex = 7)) + + assertEquals(3, signatureDao.getMaxOrderIndex("d-1")) + assertEquals(7, signatureDao.getMaxOrderIndex("d-2")) + } + + @Test + fun signature_delete_entfernt_nur_die_angegebene_signatur() = runDbTest { + seedDocumentation() + signatureDao.insert(signatureEntity(id = "s-1", orderIndex = 0)) + signatureDao.insert(signatureEntity(id = "s-2", orderIndex = 1)) + + signatureDao.delete(signatureEntity(id = "s-1")) + + assertNull(signatureDao.getById("s-1")) + assertEquals("s-2", signatureDao.getById("s-2")?.id) + } + + @Test + fun signature_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest { + seedDocumentation() + signatureDao.insert(signatureEntity(id = "s-1")) + + expectThrows { signatureDao.insert(signatureEntity(id = "s-1", signerName = "Anderer")) } + expectThrows { signatureDao.insert(signatureEntity(id = "s-2", documentationId = "gibt-es-nicht")) } + + assertEquals(1, countRows("signatures")) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/dao/SearchQueryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/SearchQueryTest.kt new file mode 100644 index 0000000..bdceaa8 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/SearchQueryTest.kt @@ -0,0 +1,229 @@ +package de.medapp.wunddoku.data.dao + +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.awaitMatching +import de.medapp.wunddoku.testutil.documentationEntity +import de.medapp.wunddoku.testutil.runDbTest +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Suchtests (QA-002): [DocumentationDao.search] und [PatientDao.searchWithLastDocumentation]. + * + * Szenario (Erstelldatum in Klammern): + * - p-1 Max Mustermann, 1980-03-14: d-1 (1000), d-2 (3000) + * - p-2 Erika Musterfrau, 1975-12-01: d-3 (2000) + * - p-3 Hans Beispiel, 1990-07-30: d-4 (500) + * - p-4 Zora Zimmer, 2001-01-01: keine Dokumentation + */ +class SearchQueryTest : RoomTestBase() { + + private suspend fun seedDoc(id: String, patientId: String, createdAt: Long, first: String, last: String, birth: String) { + documentationDao.insert( + documentationEntity( + id = id, patientId = patientId, createdAt = createdAt, + firstNameSnapshot = first, lastNameSnapshot = last, birthDateSnapshot = birth, + ) + ) + } + + private suspend fun seedScenario() { + seedPatient("p-1", "Max", "Mustermann", "1980-03-14") + seedPatient("p-2", "Erika", "Musterfrau", "1975-12-01") + seedPatient("p-3", "Hans", "Beispiel", "1990-07-30") + seedPatient("p-4", "Zora", "Zimmer", "2001-01-01") + seedDoc("d-1", "p-1", 1_000L, "Max", "Mustermann", "1980-03-14") + seedDoc("d-2", "p-1", 3_000L, "Max", "Mustermann", "1980-03-14") + seedDoc("d-3", "p-2", 2_000L, "Erika", "Musterfrau", "1975-12-01") + seedDoc("d-4", "p-3", 500L, "Hans", "Beispiel", "1990-07-30") + } + + private suspend fun searchDocs(query: String) = documentationDao.search(query).first().map { it.id } + + // --- Dokumentationssuche ------------------------------------------------------------- + + @Test + fun dokumentationssuche_findet_ueber_den_nachnamen_neueste_zuerst() = runDbTest { + seedScenario() + + assertEquals(listOf("d-2", "d-3", "d-1"), searchDocs("Muster")) + assertEquals(listOf("d-2", "d-1"), searchDocs("Mustermann")) + } + + @Test + fun dokumentationssuche_findet_ueber_den_vornamen_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest { + seedScenario() + + assertEquals(listOf("d-3"), searchDocs("erika")) + assertEquals(listOf("d-3"), searchDocs("ERIKA")) + } + + @Test + fun dokumentationssuche_findet_ueber_das_geburtsdatum_auch_teilweise() = runDbTest { + seedScenario() + + assertEquals(listOf("d-4"), searchDocs("1990-07")) + assertEquals(listOf("d-2", "d-1"), searchDocs("1980")) + assertEquals(listOf("d-3"), searchDocs("12-01")) + } + + @Test + fun dokumentationssuche_ist_ein_teilstring_abgleich() = runDbTest { + seedScenario() + + assertEquals(listOf("d-2", "d-1"), searchDocs("term")) + } + + @Test + fun dokumentationssuche_ohne_treffer_liefert_leere_liste() = runDbTest { + seedScenario() + + assertTrue(searchDocs("Zzz").isEmpty()) + } + + @Test + fun dokumentationssuche_liefert_keine_dubletten_wenn_mehrere_spalten_treffen() = runDbTest { + seedScenario() + + // "e" trifft bei p-2 gleichzeitig Vor- und Nachnamen (Erika / Musterfrau). + val ids = searchDocs("e") + + assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), ids) + assertEquals(ids.size, ids.toSet().size) + } + + @Test + fun dokumentationssuche_findet_keine_patienten_ohne_dokumentation() = runDbTest { + seedScenario() + + assertTrue(searchDocs("Zora").isEmpty()) + } + + @Test + fun dokumentationssuche_mit_leerem_text_liefert_alle_dokumentationen_der_schutz_davor_liegt_im_repository() = runDbTest { + seedScenario() + + assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), searchDocs("")) + } + + @Test + fun dokumentationssuche_sucht_gegen_den_aktuellen_namen_und_nicht_gegen_den_snapshot() = runDbTest { + seedScenario() + patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname")) + + assertEquals(listOf("d-2", "d-1"), searchDocs("Neuname")) + assertTrue(searchDocs("Mustermann").isEmpty()) + // Die gelieferten Zeilen zeigen weiterhin den eingefrorenen Namen. + val hits = documentationDao.search("Neuname").first() + assertTrue(hits.all { it.patientLastNameSnapshot == "Mustermann" }) + } + + @Test + fun dokumentationssuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest { + seedScenario() + + assertTrue(searchDocs("'; DROP TABLE patients; --").isEmpty()) + assertTrue(searchDocs("\"").isEmpty()) + assertTrue(searchDocs("O'Brien").isEmpty()) + assertEquals(4, countRows("patients")) + } + + @Test + fun dokumentationssuche_aktualisiert_sich_bei_neuen_dokumentationen_und_namensaenderungen() = runDbTest { + seedScenario() + + val afterNewDoc = async { documentationDao.search("Muster").awaitMatching { it.size == 4 } } + seedDoc("d-5", "p-1", 4_000L, "Max", "Mustermann", "1980-03-14") + assertEquals("d-5", afterNewDoc.await().first().id) + + val afterRename = async { documentationDao.search("Neuname").awaitMatching { it.size == 3 } } + patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname")) + assertEquals(listOf("d-5", "d-2", "d-1"), afterRename.await().map { it.id }) + } + + // --- Patientensuche ------------------------------------------------------------------ + + private suspend fun searchPatients(query: String) = patientDao.searchWithLastDocumentation(query).first() + + @Test + fun patientensuche_sortiert_treffer_nach_nachname_und_liefert_letztes_dokumentationsdatum() = runDbTest { + seedScenario() + + val result = searchPatients("Muster") + + assertEquals(listOf("p-2", "p-1"), result.map { it.patient.id }) // Musterfrau vor Mustermann + assertEquals(listOf(2_000L, 3_000L), result.map { it.lastDocumentationAt }) + } + + @Test + fun patientensuche_findet_ueber_vorname_nachname_und_geburtsdatum() = runDbTest { + seedScenario() + + assertEquals(listOf("p-3"), searchPatients("han").map { it.patient.id }) + assertEquals(listOf("p-3"), searchPatients("beispiel").map { it.patient.id }) + assertEquals(listOf("p-2"), searchPatients("1975").map { it.patient.id }) + assertTrue(searchPatients("Zzz").isEmpty()) + } + + @Test + fun patientensuche_findet_auch_patienten_ohne_dokumentation_mit_null_datum() = runDbTest { + seedScenario() + + val result = searchPatients("Zora").single() + + assertEquals("p-4", result.patient.id) + assertNull(result.lastDocumentationAt) + } + + @Test + fun patientensuche_liefert_keine_dubletten_trotz_mehrerer_dokumentationen_und_treffer_spalten() = runDbTest { + seedScenario() + + val ids = searchPatients("e").map { it.patient.id } + + assertEquals(ids.size, ids.toSet().size) + assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), ids) // Beispiel, Musterfrau, Mustermann, Zimmer + } + + @Test + fun patientensuche_mit_leerem_text_liefert_alle_patienten_sortiert() = runDbTest { + seedScenario() + + assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), searchPatients("").map { it.patient.id }) + } + + @Test + fun patientensuche_sortiert_bei_gleichem_nachnamen_nach_vornamen() = runDbTest { + seedPatient("p-1", "Max", "Müller", "1980-01-01") + seedPatient("p-2", "Anna", "Müller", "1981-01-01") + + assertEquals(listOf("p-2", "p-1"), searchPatients("Müller").map { it.patient.id }) + } + + @Test + fun patientensuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest { + seedScenario() + + assertTrue(searchPatients("'; DROP TABLE patients; --").isEmpty()) + assertEquals(4, countRows("patients")) + } + + @Test + fun patientensuche_aktualisiert_sich_bei_neuen_patienten_und_dokumentationen() = runDbTest { + seedScenario() + + val afterNewPatient = async { patientDao.searchWithLastDocumentation("Muster").awaitMatching { it.size == 3 } } + seedPatient("p-5", "Karl", "Musterkind", "2010-05-05") + assertEquals(3, afterNewPatient.await().size) + + val afterNewDoc = async { + patientDao.searchWithLastDocumentation("Musterkind").awaitMatching { list -> list.singleOrNull()?.lastDocumentationAt == 8_000L } + } + seedDoc("d-9", "p-5", 8_000L, "Karl", "Musterkind", "2010-05-05") + afterNewDoc.await() + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/dao/WoundCarerDaoTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/WoundCarerDaoTest.kt new file mode 100644 index 0000000..d1fbd14 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/dao/WoundCarerDaoTest.kt @@ -0,0 +1,128 @@ +package de.medapp.wunddoku.data.dao + +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.runDbTest +import de.medapp.wunddoku.testutil.woundCarerEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** DAO-Tests für [WoundCarerDao] (Wundversorger-Register). */ +class WoundCarerDaoTest : RoomTestBase() { + + // --- insertOrUpdate ------------------------------------------------------------------ + + @Test + fun insertOrUpdate_legt_neuen_eintrag_an() = runDbTest { + val carer = woundCarerEntity(updatedAt = 5_000L) + + woundCarerDao.insertOrUpdate(carer) + + assertEquals(carer, woundCarerDao.getMostRecentlyUsed()) + } + + @Test + fun insertOrUpdate_ersetzt_vorhandenen_eintrag_mit_gleicher_id_vollstaendig() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", signatureFileName = "alt.png", updatedAt = 1_000L)) + + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt-Neu", signatureFileName = "neu.png", updatedAt = 2_000L)) + + assertEquals(1, countRows("wound_carers")) + val loaded = woundCarerDao.getMostRecentlyUsed()!! + assertEquals("Anna Schmidt-Neu", loaded.name) + assertEquals("neu.png", loaded.signatureFileName) + assertEquals(2_000L, loaded.updatedAt) + } + + @Test + fun insertOrUpdate_mit_verschiedenen_ids_und_gleichem_namen_legt_zwei_eintraege_an() = runDbTest { + // Namens-Eindeutigkeit stellt das Repository (findByName) sicher, nicht die Datenbank. + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna")) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna")) + + assertEquals(2, countRows("wound_carers")) + } + + // --- findByName ---------------------------------------------------------------------- + + @Test + fun findByName_ist_ein_exakter_abgleich_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt")) + + assertEquals("w-1", woundCarerDao.findByName("Anna Schmidt")?.id) + assertEquals("w-1", woundCarerDao.findByName("anna schmidt")?.id) + assertEquals("w-1", woundCarerDao.findByName("ANNA SCHMIDT")?.id) + } + + @Test + fun findByName_trifft_nicht_bei_teilstring_oder_praefix() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt")) + + assertNull(woundCarerDao.findByName("Anna")) + assertNull(woundCarerDao.findByName("Schmidt")) + assertNull(woundCarerDao.findByName("Anna Schmidt ")) + assertNull(woundCarerDao.findByName("unbekannt")) + } + + // --- searchByNamePrefix -------------------------------------------------------------- + + @Test + fun searchByNamePrefix_liefert_praefix_treffer_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", updatedAt = 1_000L)) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Andreas Müller", updatedAt = 2_000L)) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Bernd Beispiel", updatedAt = 3_000L)) + + assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("an", 10).map { it.id }) + assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("AN", 10).map { it.id }) + assertEquals(listOf("w-3"), woundCarerDao.searchByNamePrefix("Bern", 10).map { it.id }) + } + + @Test + fun searchByNamePrefix_ist_kein_teilstring_abgleich() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt")) + + assertTrue(woundCarerDao.searchByNamePrefix("Schmidt", 10).isEmpty()) + assertTrue(woundCarerDao.searchByNamePrefix("nna", 10).isEmpty()) + } + + @Test + fun searchByNamePrefix_sortiert_nach_letzter_verwendung_absteigend_und_beachtet_das_limit() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna A", updatedAt = 1_000L)) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna B", updatedAt = 4_000L)) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Anna C", updatedAt = 3_000L)) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-4", name = "Anna D", updatedAt = 2_000L)) + + assertEquals(listOf("w-2", "w-3", "w-4", "w-1"), woundCarerDao.searchByNamePrefix("Anna", 10).map { it.id }) + assertEquals(listOf("w-2", "w-3"), woundCarerDao.searchByNamePrefix("Anna", 2).map { it.id }) + assertTrue(woundCarerDao.searchByNamePrefix("Anna", 0).isEmpty()) + } + + @Test + fun searchByNamePrefix_mit_leerem_praefix_liefert_alle_eintraege_der_schutz_davor_liegt_im_repository() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna")) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 2_000L)) + + assertEquals(2, woundCarerDao.searchByNamePrefix("", 10).size) + } + + // --- getMostRecentlyUsed ------------------------------------------------------------- + + @Test + fun getMostRecentlyUsed_ist_null_bei_leerer_tabelle() = runDbTest { + assertNull(woundCarerDao.getMostRecentlyUsed()) + } + + @Test + fun getMostRecentlyUsed_liefert_den_eintrag_mit_dem_hoechsten_updatedAt() = runDbTest { + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 1_000L)) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 3_000L)) + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Clara", updatedAt = 2_000L)) + + assertEquals("w-2", woundCarerDao.getMostRecentlyUsed()?.id) + + woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 9_000L)) + + assertEquals("w-1", woundCarerDao.getMostRecentlyUsed()?.id) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/database/DatabaseTransactionTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/database/DatabaseTransactionTest.kt new file mode 100644 index 0000000..5759fe8 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/database/DatabaseTransactionTest.kt @@ -0,0 +1,222 @@ +package de.medapp.wunddoku.data.database + +import android.database.sqlite.SQLiteConstraintException +import androidx.room.withTransaction +import de.medapp.wunddoku.testutil.RoomTestBase +import de.medapp.wunddoku.testutil.blockStateEntity +import de.medapp.wunddoku.testutil.checkItemStateEntity +import de.medapp.wunddoku.testutil.documentationEntity +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.photoEntity +import de.medapp.wunddoku.testutil.runDbTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Transaction-Tests (QA-002): Room-Transaktionen (`db.withTransaction`) müssen Mehrtabellen- + * Schreibvorgänge - wie in DocumentationRepository.createEmpty()/continueDocumentation() - + * atomar machen. + * + * Hinweis: In-Memory-Datenbanken haben nur eine Verbindung. Deshalb wird innerhalb einer + * Transaktion ausschließlich über DAOs gelesen, nie über Roh-SQL des Test-Threads. + */ +class DatabaseTransactionTest : RoomTestBase() { + + // --- Commit -------------------------------------------------------------------------- + + @Test + fun commit_macht_alle_schreibvorgaenge_sichtbar() = runDbTest { + seedPatient() + + db.withTransaction { + documentationDao.insert(documentationEntity()) + blockStateDao.insertAll(listOf(blockStateEntity())) + checkItemStateDao.insertAll(listOf(checkItemStateEntity())) + photoDao.insert(photoEntity()) + } + + assertEquals(1, countRows("documentations")) + assertEquals(1, countRows("block_states")) + assertEquals(1, countRows("check_item_states")) + assertEquals(1, countRows("photos")) + } + + @Test + fun transaktion_gibt_das_ergebnis_des_blocks_zurueck() = runDbTest { + val result = db.withTransaction { + seedPatient() + 42 + } + + assertEquals(42, result) + assertNotNull(patientDao.getById("p-1")) + } + + @Test + fun innerhalb_der_transaktion_sind_die_eigenen_schreibvorgaenge_bereits_lesbar() = runDbTest { + seedPatient() + + db.withTransaction { + documentationDao.insert(documentationEntity()) + assertNotNull(documentationDao.getById("d-1")) + blockStateDao.insertAll(listOf(blockStateEntity())) + assertEquals(1, blockStateDao.getForDocumentation("d-1").size) + } + } + + // --- Rollback ------------------------------------------------------------------------ + + @Test + fun exception_im_block_verwirft_alle_schreibvorgaenge_und_wird_weitergereicht() = runDbTest { + seedPatient() + + val error = expectThrows { + db.withTransaction { + documentationDao.insert(documentationEntity()) + blockStateDao.insertAll(listOf(blockStateEntity())) + checkItemStateDao.insertAll(listOf(checkItemStateEntity())) + error("Abbruch mitten in der Transaktion") + } + } + + assertEquals("Abbruch mitten in der Transaktion", error.message) + assertNull(documentationDao.getById("d-1")) + assertEquals(0, countRows("block_states")) + assertEquals(0, countRows("check_item_states")) + assertNotNull("Daten vor der Transaktion bleiben bestehen", patientDao.getById("p-1")) + } + + @Test + fun constraint_verletzung_in_einem_spaeteren_schritt_verwirft_auch_die_frueheren_inserts() = runDbTest { + seedPatient() + + expectThrows { + db.withTransaction { + documentationDao.insert(documentationEntity()) + photoDao.insert(photoEntity(documentationId = "gibt-es-nicht")) // FK-Verletzung + } + } + + assertNull(documentationDao.getById("d-1")) + assertEquals(0, countRows("photos")) + } + + @Test + fun insertAll_ist_atomar_ein_fehlerhafter_eintrag_verwirft_die_ganze_liste() = runDbTest { + seedDocumentation() + + expectThrows { + blockStateDao.insertAll( + listOf( + blockStateEntity(id = "b-1", blockDefinitionId = "wundart"), + blockStateEntity(id = "b-2", blockDefinitionId = "groesse"), + blockStateEntity(id = "b-3", blockDefinitionId = "wundart"), // verletzt UNIQUE + ) + ) + } + + assertEquals(0, countRows("block_states")) + } + + @Test + fun rollback_nimmt_ein_cascade_loeschen_wieder_zurueck() = runDbTest { + val doc = seedDocumentation() + blockStateDao.insertAll(listOf(blockStateEntity())) + checkItemStateDao.insertAll(listOf(checkItemStateEntity())) + photoDao.insert(photoEntity()) + + expectThrows { + db.withTransaction { + documentationDao.delete(doc) + assertNull(documentationDao.getById("d-1")) + error("Abbruch nach dem Löschen") + } + } + + assertNotNull(documentationDao.getById("d-1")) + assertEquals(1, countRows("block_states")) + assertEquals(1, countRows("check_item_states")) + assertEquals(1, countRows("photos")) + } + + @Test + fun datenbank_bleibt_nach_einer_fehlgeschlagenen_transaktion_voll_nutzbar() = runDbTest { + seedPatient() + expectThrows { + db.withTransaction { + documentationDao.insert(documentationEntity(id = "d-verloren")) + error("Abbruch") + } + } + + db.withTransaction { documentationDao.insert(documentationEntity(id = "d-ok")) } + documentationDao.insert(documentationEntity(id = "d-direkt")) + + assertNull(documentationDao.getById("d-verloren")) + assertNotNull(documentationDao.getById("d-ok")) + assertNotNull(documentationDao.getById("d-direkt")) + } + + // --- Verschachtelung ----------------------------------------------------------------- + + @Test + fun verschachtelte_transaktionen_commiten_gemeinsam() = runDbTest { + seedPatient() + + db.withTransaction { + documentationDao.insert(documentationEntity(id = "d-aussen")) + db.withTransaction { documentationDao.insert(documentationEntity(id = "d-innen")) } + } + + assertEquals(2, countRows("documentations")) + } + + @Test + fun fehler_in_der_inneren_transaktion_rollt_auch_die_aeussere_zurueck_selbst_wenn_er_gefangen_wird() = runDbTest { + // SQLite/Android: Wird eine verschachtelte Transaktion nicht als erfolgreich beendet, + // wird die äußere ebenfalls zurückgerollt (SQLiteDatabase.beginTransaction-Doku). + seedPatient() + + db.withTransaction { + documentationDao.insert(documentationEntity(id = "d-aussen")) + try { + db.withTransaction { + documentationDao.insert(documentationEntity(id = "d-innen")) + error("Fehler innen") + } + } catch (e: IllegalStateException) { + // bewusst gefangen: "e" ist unbenutzt, der Fehler soll die äußere Transaktion nicht abbrechen + } + } + + assertEquals(0, countRows("documentations")) + } + + // --- Nebenläufigkeit ----------------------------------------------------------------- + + @Test + fun parallele_transaktionen_werden_serialisiert_und_verlieren_keine_schreibvorgaenge() = runDbTest { + seedDocumentation() + + coroutineScope { + listOf("a", "b", "c").map { prefix -> + async(Dispatchers.Default) { + repeat(20) { i -> + db.withTransaction { + photoDao.insert(photoEntity(id = "$prefix-$i", fileName = "$prefix$i.jpg", orderIndex = i)) + } + } + } + }.awaitAll() + } + + assertEquals(60, countRows("photos")) + assertEquals(60, photoDao.getForDocumentation("d-1").size) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/EntityFactory.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/EntityFactory.kt new file mode 100644 index 0000000..869b014 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/EntityFactory.kt @@ -0,0 +1,154 @@ +package de.medapp.wunddoku.testutil + +import de.medapp.wunddoku.data.entity.BlockStateEntity +import de.medapp.wunddoku.data.entity.CheckItemStateEntity +import de.medapp.wunddoku.data.entity.DocumentationEntity +import de.medapp.wunddoku.data.entity.PatientEntity +import de.medapp.wunddoku.data.entity.PhotoEntity +import de.medapp.wunddoku.data.entity.SignatureEntity +import de.medapp.wunddoku.data.entity.WoundCarerEntity +import de.medapp.wunddoku.domain.model.DocumentationStatus + +/** Builder für Room-Entities mit sinnvollen Standardwerten - Tests überschreiben nur, was sie brauchen. */ + +fun patientEntity( + id: String = "p-1", + firstName: String = "Max", + lastName: String = "Mustermann", + birthDate: String = "1980-03-14", + street: String = "Musterstraße 1", + postalCode: String = "12345", + city: String = "Musterstadt", + phone1: String = "0123 456789", + phone2: String? = null, + phone3: String? = null, + createdAt: Long = 1_000L, + updatedAt: Long = createdAt, +) = PatientEntity( + id = id, + firstName = firstName, + lastName = lastName, + birthDate = birthDate, + street = street, + postalCode = postalCode, + city = city, + phone1 = phone1, + phone2 = phone2, + phone3 = phone3, + createdAt = createdAt, + updatedAt = updatedAt, +) + +fun documentationEntity( + id: String = "d-1", + patientId: String = "p-1", + status: DocumentationStatus = DocumentationStatus.IN_PROGRESS, + createdAt: Long = 1_000L, + updatedAt: Long = createdAt, + continuedFromDocumentationId: String? = null, + pdfLastGeneratedAt: Long? = null, + catalogVersion: Int = 4, + firstNameSnapshot: String = "Max", + lastNameSnapshot: String = "Mustermann", + birthDateSnapshot: String = "1980-03-14", + streetSnapshot: String = "Musterstraße 1", + postalCodeSnapshot: String = "12345", + citySnapshot: String = "Musterstadt", +) = DocumentationEntity( + id = id, + patientId = patientId, + status = status, + createdAt = createdAt, + updatedAt = updatedAt, + continuedFromDocumentationId = continuedFromDocumentationId, + pdfLastGeneratedAt = pdfLastGeneratedAt, + catalogVersion = catalogVersion, + patientFirstNameSnapshot = firstNameSnapshot, + patientLastNameSnapshot = lastNameSnapshot, + patientBirthDateSnapshot = birthDateSnapshot, + patientStreetSnapshot = streetSnapshot, + patientPostalCodeSnapshot = postalCodeSnapshot, + patientCitySnapshot = citySnapshot, +) + +fun blockStateEntity( + id: String = "b-1", + documentationId: String = "d-1", + blockDefinitionId: String = "wundart", + title: String = "Wundart", + order: Int = 1, + updatedAt: Long = 1_000L, +) = BlockStateEntity( + id = id, + documentationId = documentationId, + blockDefinitionId = blockDefinitionId, + titleSnapshot = title, + blockOrder = order, + updatedAt = updatedAt, +) + +fun checkItemStateEntity( + id: String = "c-1", + blockStateId: String = "b-1", + definitionId: String = "item_1", + label: String = "Eintrag 1", + order: Int = 1, + checked: Boolean = false, + isFreeText: Boolean = false, + freeTextValue: String = "", + isStaticHint: Boolean = false, + inputSpec: String = "", +) = CheckItemStateEntity( + id = id, + blockStateId = blockStateId, + checkItemDefinitionId = definitionId, + labelSnapshot = label, + itemOrder = order, + checked = checked, + isFreeText = isFreeText, + freeTextValue = freeTextValue, + isStaticHint = isStaticHint, + inputSpec = inputSpec, +) + +fun photoEntity( + id: String = "ph-1", + documentationId: String = "d-1", + fileName: String = "foto.jpg", + orderIndex: Int = 0, + createdAt: Long = 1_000L, +) = PhotoEntity( + id = id, + documentationId = documentationId, + fileName = fileName, + orderIndex = orderIndex, + createdAt = createdAt, +) + +fun signatureEntity( + id: String = "s-1", + documentationId: String = "d-1", + signerName: String = "Anna Schmidt", + fileName: String = "unterschrift.png", + orderIndex: Int = 0, + createdAt: Long = 1_000L, +) = SignatureEntity( + id = id, + documentationId = documentationId, + signerName = signerName, + fileName = fileName, + orderIndex = orderIndex, + createdAt = createdAt, +) + +fun woundCarerEntity( + id: String = "w-1", + name: String = "Anna Schmidt", + signatureFileName: String = "w-sig.png", + updatedAt: Long = 1_000L, +) = WoundCarerEntity( + id = id, + name = name, + signatureFileName = signatureFileName, + updatedAt = updatedAt, +) diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/RoomTestBase.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/RoomTestBase.kt new file mode 100644 index 0000000..673a40c --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/RoomTestBase.kt @@ -0,0 +1,107 @@ +package de.medapp.wunddoku.testutil + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import de.medapp.wunddoku.data.dao.BlockStateDao +import de.medapp.wunddoku.data.dao.CheckItemStateDao +import de.medapp.wunddoku.data.dao.DocumentationDao +import de.medapp.wunddoku.data.dao.PatientDao +import de.medapp.wunddoku.data.dao.PhotoDao +import de.medapp.wunddoku.data.dao.SignatureDao +import de.medapp.wunddoku.data.dao.WoundCarerDao +import de.medapp.wunddoku.data.database.AppDatabase +import de.medapp.wunddoku.data.entity.DocumentationEntity +import de.medapp.wunddoku.data.entity.PatientEntity +import de.medapp.wunddoku.domain.model.DocumentationStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith + +/** + * Basisklasse für alle Room-Tests (QA-002): echte In-Memory-Datenbank mit demselben Schema, + * denselben Foreign Keys und denselben Indizes wie in Produktion. Jeder Test bekommt eine + * frische, leere Datenbank. + */ +@RunWith(AndroidJUnit4::class) +abstract class RoomTestBase { + + protected lateinit var db: AppDatabase + + protected val patientDao: PatientDao get() = db.patientDao() + protected val documentationDao: DocumentationDao get() = db.documentationDao() + protected val blockStateDao: BlockStateDao get() = db.blockStateDao() + protected val checkItemStateDao: CheckItemStateDao get() = db.checkItemStateDao() + protected val photoDao: PhotoDao get() = db.photoDao() + protected val signatureDao: SignatureDao get() = db.signatureDao() + protected val woundCarerDao: WoundCarerDao get() = db.woundCarerDao() + + @Before + fun createDatabase() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).build() + } + + @After + fun closeDatabase() { + db.close() + } + + // --- Seed-Helfer --------------------------------------------------------------------- + + protected suspend fun seedPatient( + id: String = "p-1", + firstName: String = "Max", + lastName: String = "Mustermann", + birthDate: String = "1980-03-14", + ): PatientEntity { + val entity = patientEntity(id = id, firstName = firstName, lastName = lastName, birthDate = birthDate) + patientDao.insert(entity) + return entity + } + + /** Legt eine Dokumentation an; fehlt der Patient, wird er mit Standarddaten angelegt. */ + protected suspend fun seedDocumentation( + id: String = "d-1", + patientId: String = "p-1", + createdAt: Long = 1_000L, + status: DocumentationStatus = DocumentationStatus.IN_PROGRESS, + ): DocumentationEntity { + if (patientDao.getById(patientId) == null) seedPatient(id = patientId) + val entity = documentationEntity(id = id, patientId = patientId, createdAt = createdAt, status = status) + documentationDao.insert(entity) + return entity + } + + // --- Roh-SQL-Helfer (unabhängig von DAO-Logik prüfen, was wirklich in der DB steht) ---- + + protected fun queryInt(sql: String): Int = + db.openHelper.readableDatabase.query(sql).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + protected fun queryString(sql: String): String = + db.openHelper.readableDatabase.query(sql).use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + + protected fun countRows(table: String): Int = queryInt("SELECT COUNT(*) FROM $table") + + protected fun execSql(sql: String) { + db.openHelper.writableDatabase.execSQL(sql) + } +} + +/** + * Wartet (mit Timeout), bis ein Flow einen Wert liefert, der [predicate] erfüllt. + * Robust für Reaktivitäts-Tests, egal ob Änderung vor oder nach dem ersten Emit passiert. + */ +suspend fun Flow.awaitMatching(timeoutMs: Long = 5_000L, predicate: (T) -> Boolean): T = + withTimeout(timeoutMs) { first { predicate(it) } }