From dcfa11bef9b622b94d10714498e287c884db887a Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Thu, 8 Oct 2026 12:15:31 +0200 Subject: [PATCH 1/3] QA-001 - [x] Dateinamen-Tests vorhanden - [x] Fortsetzen-Tests vorhanden - [x] Blockstatus-Tests vorhanden - [x] Repository-Tests vorhanden --- .../repository/ContinueDocumentationTest.kt | 378 ++++++++++++ .../repository/DocumentationRepositoryTest.kt | 545 ++++++++++++++++++ .../data/repository/PatientRepositoryTest.kt | 222 +++++++ .../repository/WoundCarerRepositoryTest.kt | 156 +++++ .../java/de/medapp/wunddoku/testutil/Fakes.kt | 138 +++++ .../wunddoku/testutil/TestEnvironment.kt | 133 +++++ .../domain/usecase/BlockEditedPolicyTest.kt | 151 +++++ ...DocumentationToPdfExportModelMapperTest.kt | 245 ++++++++ .../wunddoku/pdf/PdfFileNameGeneratorTest.kt | 175 ++++++ 9 files changed, 2143 insertions(+) create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt new file mode 100644 index 0000000..16d444b --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt @@ -0,0 +1,378 @@ +package de.medapp.wunddoku.data.repository + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.domain.catalog.DocumentationCatalog +import de.medapp.wunddoku.domain.model.CheckItem +import de.medapp.wunddoku.domain.model.Documentation +import de.medapp.wunddoku.domain.model.DocumentationStatus +import de.medapp.wunddoku.testutil.DuplicateCall +import de.medapp.wunddoku.testutil.TestEnvironment +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.firstPlainItem +import de.medapp.wunddoku.testutil.runDbTest +import de.medapp.wunddoku.testutil.signatureBitmap +import kotlinx.coroutines.flow.first +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import java.time.LocalDate + +/** + * Fortsetzen-Tests: "Dokumentation fortsetzen" erzeugt aus dem Stand einer vorhandenen + * Dokumentation eine NEUE; die Quelle bleibt unverändert. + */ +@RunWith(AndroidJUnit4::class) +class ContinueDocumentationTest { + + private lateinit var env: TestEnvironment + private val repo get() = env.documentationRepository + + @Before + fun setUp() { + env = TestEnvironment() + } + + @After + fun tearDown() { + env.close() + } + + /** Alle Items eines Dokuments als Map definitionId (inkl. Blockbezug) -> Item. */ + private fun Documentation.itemsByKey(): Map = + blocks.flatMap { block -> block.items.map { "${block.definitionId}/${it.definitionId}" to it } }.toMap() + + /** Bereitet eine Quelle mit einigen Eingaben vor und liefert sie frisch geladen zurück. */ + private suspend fun prepareSourceWithInput(): Documentation { + env.insertPatient() + val created = repo.createEmpty("patient-1") + val firstBlock = created.blocks.first() + repo.setChecked(firstBlock.firstPlainItem().checkItemStateId, true) + val freeText = firstBlock.items.last() + repo.setChecked(freeText.checkItemStateId, true) + repo.updateFreeText(freeText.checkItemStateId, "Rötung am Wundrand") + return repo.getFullDocumentation(created.id)!! + } + + // --- Inhalt wird übernommen ---------------------------------------------------------- + + @Test + fun fortsetzen_uebernimmt_bloecke_und_optionen_inklusive_haken_und_freitext() = runDbTest { + val source = prepareSourceWithInput() + + val copy = repo.continueDocumentation(source.id) + + assertEquals(source.blocks.size, copy.blocks.size) + for (sourceBlock in source.blocks) { + val copyBlock = copy.blocks.single { it.definitionId == sourceBlock.definitionId } + assertEquals(sourceBlock.title, copyBlock.title) + assertEquals(sourceBlock.order, copyBlock.order) + assertEquals(sourceBlock.isEdited, copyBlock.isEdited) + } + // Option für Option: gleiche Inhalte (Label, Reihenfolge, Haken, Freitext, Hinweis, strukturierte Eingabe). + val sourceItems = source.itemsByKey() + val copyItems = copy.itemsByKey() + assertEquals(sourceItems.keys, copyItems.keys) + for ((key, sourceItem) in sourceItems) { + val copyItem = copyItems.getValue(key) + assertEquals(key, sourceItem.label, copyItem.label) + assertEquals(key, sourceItem.order, copyItem.order) + assertEquals(key, sourceItem.checked, copyItem.checked) + assertEquals(key, sourceItem.isFreeText, copyItem.isFreeText) + assertEquals(key, sourceItem.freeTextValue, copyItem.freeTextValue) + assertEquals(key, sourceItem.isStaticHint, copyItem.isStaticHint) + assertEquals(key, sourceItem.inputSpec, copyItem.inputSpec) + } + assertEquals(1, copy.blocks.count { it.isEdited }) + assertEquals("Rötung am Wundrand", copy.blocks.first().items.last().freeTextValue) + } + + @Test + fun fortsetzen_erzeugt_ausschliesslich_neue_ids() = runDbTest { + val source = prepareSourceWithInput() + + val copy = repo.continueDocumentation(source.id) + + assertNotEquals(source.id, copy.id) + val sourceBlockIds = source.blocks.map { it.blockStateId }.toSet() + val sourceItemIds = source.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.toSet() + assertTrue(copy.blocks.none { it.blockStateId in sourceBlockIds }) + assertTrue(copy.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.none { it in sourceItemIds }) + } + + @Test + fun fortsetzen_setzt_metadaten_der_neuen_dokumentation_korrekt() = runDbTest { + val source = prepareSourceWithInput() + env.setStatus(source.id, DocumentationStatus.COMPLETED) + + val copy = repo.continueDocumentation(source.id) + + assertEquals(DocumentationStatus.IN_PROGRESS, copy.status) + assertEquals(source.id, copy.continuedFromDocumentationId) + assertEquals(source.patientId, copy.patientId) + assertEquals(copy.createdAt, copy.updatedAt) + assertEquals(DocumentationStatus.COMPLETED, repo.getFullDocumentation(source.id)!!.status) + assertEquals(2, env.db.documentationDao().countForPatient("patient-1")) + assertTrue(env.patientRepository.hasDocumentations("patient-1")) + } + + // --- Quelle bleibt unverändert / Unabhängigkeit -------------------------------------- + + @Test + fun fortsetzen_laesst_die_quelldokumentation_unveraendert() = runDbTest { + val source = prepareSourceWithInput() + + repo.continueDocumentation(source.id) + + val sourceAfter = repo.getFullDocumentation(source.id)!! + assertEquals(source.status, sourceAfter.status) + assertEquals(source.createdAt, sourceAfter.createdAt) + assertEquals(source.updatedAt, sourceAfter.updatedAt) + assertNull(sourceAfter.continuedFromDocumentationId) + assertEquals(source.blocks, sourceAfter.blocks) + assertEquals(source.photos, sourceAfter.photos) + assertEquals(source.signatures, sourceAfter.signatures) + } + + @Test + fun aenderungen_an_der_kopie_veraendern_die_quelle_nicht_und_umgekehrt() = runDbTest { + val source = prepareSourceWithInput() + val copy = repo.continueDocumentation(source.id) + + // Kopie: den in der Quelle gesetzten Haken entfernen und Freitext ändern. + val copyFirstBlock = copy.blocks.first() + repo.setChecked(copyFirstBlock.firstPlainItem().checkItemStateId, false) + repo.updateFreeText(copyFirstBlock.items.last().checkItemStateId, "Neuer Text") + + val sourceAfter = repo.getFullDocumentation(source.id)!! + assertTrue(sourceAfter.blocks.first().firstPlainItem().checked) + assertEquals("Rötung am Wundrand", sourceAfter.blocks.first().items.last().freeTextValue) + + // Quelle: weiteren Haken in einem anderen Block setzen. + val otherSourceBlock = source.blocks[1] + repo.setChecked(otherSourceBlock.firstPlainItem().checkItemStateId, true) + + val copyAfter = repo.getFullDocumentation(copy.id)!! + assertFalse(copyAfter.blocks.single { it.definitionId == otherSourceBlock.definitionId }.isEdited) + assertEquals("Neuer Text", copyAfter.blocks.first().items.last().freeTextValue) + } + + // --- Patienten-Snapshot -------------------------------------------------------------- + + @Test + fun fortsetzen_bildet_den_patienten_snapshot_frisch_aus_dem_aktuellen_patienten() = runDbTest { + val source = prepareSourceWithInput() + val patientDao = env.db.patientDao() + patientDao.update( + patientDao.getById("patient-1")!!.copy( + lastName = "Musterfrau", street = "Neue Straße 7", postalCode = "99999", city = "Neustadt", + ) + ) + + val copy = repo.continueDocumentation(source.id) + + assertEquals("Musterfrau", copy.patientSnapshot.lastName) + assertEquals("Neue Straße 7", copy.patientSnapshot.street) + assertEquals("99999", copy.patientSnapshot.postalCode) + assertEquals("Neustadt", copy.patientSnapshot.city) + assertEquals(LocalDate.of(1980, 3, 14), copy.patientSnapshot.birthDate) + // Die Quelle behält ihren damaligen Stand. + val sourceSnapshot = repo.getFullDocumentation(source.id)!!.patientSnapshot + assertEquals("Mustermann", sourceSnapshot.lastName) + assertEquals("Musterstraße 1", sourceSnapshot.street) + assertEquals("Musterstadt", sourceSnapshot.city) + } + + // --- Historische Snapshots / Katalogversion ------------------------------------------ + + @Test + fun fortsetzen_bewahrt_historische_titel_labels_und_katalogversion() = runDbTest { + env.insertPatient() + val now = 5_000L + val historicDocId = "historic-doc" + env.db.documentationDao().insert( + DocumentationEntity( + id = historicDocId, patientId = "patient-1", status = DocumentationStatus.COMPLETED, + createdAt = now, updatedAt = now, continuedFromDocumentationId = null, pdfLastGeneratedAt = null, + catalogVersion = 1, + patientFirstNameSnapshot = "Max", patientLastNameSnapshot = "Mustermann", + patientBirthDateSnapshot = "1980-03-14", patientStreetSnapshot = "Musterstraße 1", + patientPostalCodeSnapshot = "12345", patientCitySnapshot = "Musterstadt", + ) + ) + env.db.blockStateDao().insertAll( + listOf(BlockStateEntity("hist-block", historicDocId, "gibt_es_nicht_mehr", "Alter Titel", 1, now)) + ) + env.db.checkItemStateDao().insertAll( + listOf( + CheckItemStateEntity("hist-item", "hist-block", "alter_eintrag", "Alter Eintrag", 1, checked = true), + ) + ) + assertNotEquals("Voraussetzung: aktuelle Katalogversion weicht ab", 1, DocumentationCatalog.CATALOG_VERSION) + + val copy = repo.continueDocumentation(historicDocId) + + assertEquals(1, copy.catalogVersion) + val block = copy.blocks.single() + assertEquals("gibt_es_nicht_mehr", block.definitionId) + assertEquals("Alter Titel", block.title) + assertEquals("Alter Eintrag", block.items.single().label) + assertTrue(block.items.single().checked) + } + + // --- Unterschriften ------------------------------------------------------------------ + + @Test + fun fortsetzen_kopiert_keine_unterschriften() = runDbTest { + val source = prepareSourceWithInput() + repo.saveSignature(source.id, "Anna Schmidt", signatureBitmap()) + + val copy = repo.continueDocumentation(source.id) + + assertTrue(copy.signatures.isEmpty()) + assertEquals(1, repo.getFullDocumentation(source.id)!!.signatures.size) + assertEquals(1, env.signatureStorage.saved.size) // keine zusätzliche Datei angelegt + } + + // --- Fotos --------------------------------------------------------------------------- + + @Test + fun fortsetzen_kopiert_fotos_standardmaessig_als_eigenstaendige_dateien() = runDbTest { + val source = prepareSourceWithInput() + val p1 = repo.addPhoto(source.id, Uri.parse("content://test/1")) + val p2 = repo.addPhoto(source.id, Uri.parse("content://test/2")) + + val copy = repo.continueDocumentation(source.id) + + assertEquals(listOf(0, 1), copy.photos.map { it.orderIndex }) + assertTrue(copy.photos.none { it.id == p1.id || it.id == p2.id }) + assertTrue("Dateien müssen physisch eigenständig sein", copy.photos.none { it.fileName == p1.fileName || it.fileName == p2.fileName }) + assertTrue(copy.photos.all { it.documentationId == copy.id }) + assertEquals( + setOf( + DuplicateCall(source.id, p1.fileName, copy.id), + DuplicateCall(source.id, p2.fileName, copy.id), + ), + env.photoStorage.duplicated.toSet(), + ) + assertEquals(2, env.photoStorage.duplicated.size) + // Quelle behält ihre Fotos. + assertEquals(listOf(p1.fileName, p2.fileName), repo.getFullDocumentation(source.id)!!.photos.map { it.fileName }) + } + + @Test + fun fortsetzen_mit_copyPhotos_false_uebernimmt_keine_fotos() = runDbTest { + val source = prepareSourceWithInput() + repo.addPhoto(source.id, Uri.parse("content://test/1")) + + val copy = repo.continueDocumentation(source.id, copyPhotos = false) + + assertTrue(copy.photos.isEmpty()) + assertTrue(env.photoStorage.duplicated.isEmpty()) + } + + @Test + fun foto_loeschen_in_der_kopie_betrifft_die_quelle_nicht() = runDbTest { + val source = prepareSourceWithInput() + repo.addPhoto(source.id, Uri.parse("content://test/1")) + val copy = repo.continueDocumentation(source.id) + + repo.removePhoto(copy.photos.single().id) + + assertTrue(repo.getFullDocumentation(copy.id)!!.photos.isEmpty()) + assertEquals(1, repo.getFullDocumentation(source.id)!!.photos.size) + } + + // --- Fehlerfälle / Atomarität -------------------------------------------------------- + + @Test + fun fortsetzen_einer_unbekannten_dokumentation_wirft_und_legt_nichts_an() = runDbTest { + env.insertPatient() + + val error = expectThrows { repo.continueDocumentation("unbekannt") } + + assertTrue(error.message.orEmpty().contains("unbekannt")) + assertTrue(env.db.documentationDao().observeAll().first().isEmpty()) + } + + @Test + fun fortsetzen_ist_atomar_ein_fehler_beim_foto_kopieren_hinterlaesst_keine_halbe_dokumentation() = runDbTest { + val source = prepareSourceWithInput() + repo.addPhoto(source.id, Uri.parse("content://test/1")) + repo.addPhoto(source.id, Uri.parse("content://test/2")) + env.photoStorage.failOnDuplicateCall = 2 // zweites Foto schlägt fehl + + expectThrows { repo.continueDocumentation(source.id) } + + val all = env.db.documentationDao().observeAll().first() + assertEquals(listOf(source.id), all.map { it.id }) + assertEquals(1, env.db.documentationDao().countForPatient("patient-1")) + } + + @Test + fun loeschen_der_quelle_laesst_die_fortgesetzte_dokumentation_bestehen() = runDbTest { + val source = prepareSourceWithInput() + repo.addPhoto(source.id, Uri.parse("content://test/1")) + val copy = repo.continueDocumentation(source.id) + + repo.delete(source.id) + + val copyAfter = repo.getFullDocumentation(copy.id)!! + assertNull("Verweis auf die gelöschte Quelle wird auf null gesetzt", copyAfter.continuedFromDocumentationId) + assertEquals(copy.blocks.size, copyAfter.blocks.size) + assertEquals(1, copyAfter.photos.size) + assertEquals(listOf(source.id), env.photoStorage.deletedAll) // nur der Ordner der Quelle + } + + @Test + fun mehrfaches_fortsetzen_bildet_eine_kette() = runDbTest { + val first = prepareSourceWithInput() + + val second = repo.continueDocumentation(first.id) + val third = repo.continueDocumentation(second.id) + + assertEquals(first.id, second.continuedFromDocumentationId) + assertEquals(second.id, third.continuedFromDocumentationId) + assertEquals(1, third.blocks.count { it.isEdited }) + assertEquals(3, env.db.documentationDao().countForPatient("patient-1")) + } + + // --- continueLatestForPatient -------------------------------------------------------- + + @Test + fun continueLatestForPatient_liefert_null_wenn_der_patient_noch_keine_dokumentation_hat() = runDbTest { + env.insertPatient() + + assertNull(repo.continueLatestForPatient("patient-1")) + assertTrue(env.db.documentationDao().observeAll().first().isEmpty()) + } + + @Test + fun continueLatestForPatient_setzt_die_zuletzt_erstellte_dokumentation_des_patienten_fort() = runDbTest { + env.insertPatient() + env.insertPatient(id = "patient-2", firstName = "Erika", lastName = "Musterfrau") + val older = repo.createEmpty("patient-1") + val newer = repo.createEmpty("patient-1") + val otherPatientsDoc = repo.createEmpty("patient-2") + env.setCreatedAt(older.id, 1_000L) + env.setCreatedAt(newer.id, 2_000L) + env.setCreatedAt(otherPatientsDoc.id, 3_000L) // neuer, aber anderer Patient + repo.setChecked(newer.blocks.first().firstPlainItem().checkItemStateId, true) + + val copy = repo.continueLatestForPatient("patient-1")!! + + assertEquals(newer.id, copy.continuedFromDocumentationId) + assertEquals("patient-1", copy.patientId) + assertEquals(1, copy.blocks.count { it.isEdited }) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt new file mode 100644 index 0000000..3ce6a6e --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt @@ -0,0 +1,545 @@ +package de.medapp.wunddoku.data.repository + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.medapp.wunddoku.domain.catalog.DocumentationCatalog +import de.medapp.wunddoku.domain.model.DocumentationStatus +import de.medapp.wunddoku.domain.model.InputSpecCodec +import de.medapp.wunddoku.pdf.PdfExportError +import de.medapp.wunddoku.pdf.PdfExportResult +import de.medapp.wunddoku.pdf.PdfToImagesResult +import de.medapp.wunddoku.testutil.TestEnvironment +import de.medapp.wunddoku.testutil.blankBitmap +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.firstPlainItem +import de.medapp.wunddoku.testutil.runDbTest +import de.medapp.wunddoku.testutil.signatureBitmap +import kotlinx.coroutines.flow.first +import org.junit.After +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.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.time.LocalDate +import java.time.ZoneId + +/** + * Repository-Tests für [DocumentationRepository] gegen eine echte In-Memory-Room-Datenbank. + * Dateispeicher und PDF-Erzeugung sind durch Fakes ersetzt (s. testutil/Fakes.kt). + * "Fortsetzen" wird separat in [ContinueDocumentationTest] getestet. + */ +@RunWith(AndroidJUnit4::class) +class DocumentationRepositoryTest { + + private lateinit var env: TestEnvironment + private val repo get() = env.documentationRepository + + @Before + fun setUp() { + env = TestEnvironment() + } + + @After + fun tearDown() { + env.close() + } + + // --- createEmpty --------------------------------------------------------------------- + + @Test + fun createEmpty_legt_neue_dokumentation_im_status_in_bearbeitung_an() = runDbTest { + env.insertPatient() + + val doc = repo.createEmpty("patient-1") + + assertEquals("patient-1", doc.patientId) + assertEquals(DocumentationStatus.IN_PROGRESS, doc.status) + assertNull(doc.continuedFromDocumentationId) + assertEquals(DocumentationCatalog.CATALOG_VERSION, doc.catalogVersion) + assertEquals(doc.createdAt, doc.updatedAt) + assertTrue(doc.photos.isEmpty()) + assertTrue(doc.signatures.isEmpty()) + assertEquals(doc.id, repo.getFullDocumentation(doc.id)?.id) + } + + @Test + fun createEmpty_uebernimmt_den_patienten_snapshot() = runDbTest { + env.insertPatient( + firstName = "Erika", lastName = "Musterfrau", birthDate = "1975-12-01", + street = "Hauptstraße 5", postalCode = "54321", city = "Beispielstadt", + ) + + val snapshot = repo.createEmpty("patient-1").patientSnapshot + + assertEquals("Erika", snapshot.firstName) + assertEquals("Musterfrau", snapshot.lastName) + assertEquals(LocalDate.of(1975, 12, 1), snapshot.birthDate) + assertEquals("Hauptstraße 5", snapshot.street) + assertEquals("54321", snapshot.postalCode) + assertEquals("Beispielstadt", snapshot.city) + } + + @Test + fun createEmpty_erzeugt_pro_katalogblock_einen_block_mit_titel_und_reihenfolge_snapshot() = runDbTest { + env.insertPatient() + + val doc = repo.createEmpty("patient-1") + + assertEquals(DocumentationCatalog.blocks.size, doc.blocks.size) + for (def in DocumentationCatalog.blocks) { + val block = doc.blocks.single { it.definitionId == def.id } + assertEquals(def.title, block.title) + assertEquals(def.order, block.order) + } + } + + @Test + fun createEmpty_haengt_pro_block_die_generische_freitext_option_als_letztes_element_an() = runDbTest { + env.insertPatient() + + val doc = repo.createEmpty("patient-1") + + for (def in DocumentationCatalog.blocks) { + val block = doc.blocks.single { it.definitionId == def.id } + assertEquals(def.items.size + 1, block.items.size) + + val generic = block.items.last() + assertEquals(def.freeTextDefinitionId, generic.definitionId) + assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, generic.label) + assertEquals((def.items.maxOfOrNull { it.order } ?: 0) + 1, generic.order) + assertTrue(generic.isFreeText) + assertFalse(generic.isStaticHint) + assertFalse(generic.checked) + assertEquals("", generic.freeTextValue) + } + } + + @Test + fun createEmpty_uebernimmt_katalogeintraege_1_zu_1_und_hinweistexte_starten_angehakt() = runDbTest { + env.insertPatient() + + val doc = repo.createEmpty("patient-1") + + for (def in DocumentationCatalog.blocks) { + val block = doc.blocks.single { it.definitionId == def.id } + for (itemDef in def.items) { + val state = block.items.single { it.definitionId == itemDef.id } + assertEquals(itemDef.label, state.label) + assertEquals(itemDef.order, state.order) + assertEquals(itemDef.isFreeText, state.isFreeText) + assertEquals(itemDef.isStaticHint, state.isStaticHint) + // Nur Hinweistexte starten angehakt, alles andere unangehakt/leer. + assertEquals(itemDef.isStaticHint, state.checked) + assertEquals("", state.freeTextValue) + val expectedSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: "" + assertEquals(expectedSpec, state.inputSpec) + } + } + } + + @Test + fun createEmpty_ergibt_keinen_bearbeiteten_block_auch_nicht_durch_hinweistexte() = runDbTest { + env.insertPatient() + + val doc = repo.createEmpty("patient-1") + + assertTrue(doc.blocks.none { it.isEdited }) + } + + @Test + fun createEmpty_vergibt_fuer_jede_dokumentation_neue_ids() = runDbTest { + env.insertPatient() + + val first = repo.createEmpty("patient-1") + val second = repo.createEmpty("patient-1") + + assertTrue(first.id != second.id) + val allBlockIds = (first.blocks + second.blocks).map { it.blockStateId } + assertEquals(allBlockIds.size, allBlockIds.toSet().size) + assertEquals(2, env.db.documentationDao().countForPatient("patient-1")) + } + + @Test + fun createEmpty_mit_unbekanntem_patienten_wirft_und_legt_nichts_an() = runDbTest { + val error = expectThrows { repo.createEmpty("gibt-es-nicht") } + + assertTrue(error.message.orEmpty().contains("gibt-es-nicht")) + assertTrue(env.db.documentationDao().observeAll().first().isEmpty()) + } + + // --- Lesen / Liste / Suche ----------------------------------------------------------- + + @Test + fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest { + assertNull(repo.getFullDocumentation("unbekannt")) + } + + @Test + fun observeAll_liefert_neueste_dokumentation_zuerst_mit_snapshot_namen() = runDbTest { + env.insertPatient() + val older = repo.createEmpty("patient-1") + val newer = repo.createEmpty("patient-1") + env.setCreatedAt(older.id, 1_000L) + env.setCreatedAt(newer.id, 2_000L) + + val list = repo.observeAll().first() + + assertEquals(listOf(newer.id, older.id), list.map { it.id }) + assertEquals("Max Mustermann", list.first().patientFullName) + assertEquals(DocumentationStatus.IN_PROGRESS, list.first().status) + } + + @Test + fun search_sucht_gegen_den_aktuellen_patientennamen_zeigt_aber_den_snapshot_namen() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val patientDao = env.db.patientDao() + patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau")) + + val found = repo.search("Musterfrau").first() + + assertEquals(listOf(doc.id), found.map { it.id }) + assertEquals("Max Mustermann", found.single().patientFullName) + assertTrue(repo.search("Mustermann").first().isEmpty()) + } + + @Test + fun search_findet_auch_ueber_vornamen_und_geburtsdatum() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + + assertEquals(listOf(doc.id), repo.search("Ma").first().map { it.id }) + assertEquals(listOf(doc.id), repo.search("1980-03").first().map { it.id }) + assertTrue(repo.search("Zzz").first().isEmpty()) + } + + // --- Autosave / Blockstatus ---------------------------------------------------------- + + @Test + fun setChecked_persistiert_und_macht_den_block_bearbeitet() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val block = doc.blocks.first() + val item = block.firstPlainItem() + + repo.setChecked(item.checkItemStateId, true) + + val reloaded = repo.getFullDocumentation(doc.id)!! + val reloadedBlock = reloaded.blocks.single { it.blockStateId == block.blockStateId } + assertTrue(reloadedBlock.items.single { it.checkItemStateId == item.checkItemStateId }.checked) + assertTrue(reloadedBlock.isEdited) + assertEquals(1, reloaded.blocks.count { it.isEdited }) + } + + @Test + fun setChecked_false_nimmt_den_bearbeitet_status_wieder_zurueck() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val block = doc.blocks.first() + val item = block.firstPlainItem() + repo.setChecked(item.checkItemStateId, true) + + repo.setChecked(item.checkItemStateId, false) + + val reloaded = repo.getFullDocumentation(doc.id)!! + assertTrue(reloaded.blocks.none { it.isEdited }) + } + + @Test + fun freitext_wird_gespeichert_und_zaehlt_erst_mit_haken_als_bearbeitung() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val block = doc.blocks.first() + val freeText = block.items.last() + val text = "Größe 3,5 × 2 cm\nzweite Zeile – Rötung" + + repo.updateFreeText(freeText.checkItemStateId, text) + + val textOnly = repo.getFullDocumentation(doc.id)!! + assertEquals(text, textOnly.blocks.single { it.blockStateId == block.blockStateId }.items.last().freeTextValue) + assertTrue("Freitext ohne Haken darf den Block nicht bearbeitet machen", textOnly.blocks.none { it.isEdited }) + + repo.setChecked(freeText.checkItemStateId, true) + + val checked = repo.getFullDocumentation(doc.id)!! + val reloadedBlock = checked.blocks.single { it.blockStateId == block.blockStateId } + assertTrue(reloadedBlock.items.last().checked) + assertEquals(text, reloadedBlock.items.last().freeTextValue) + assertTrue(reloadedBlock.isEdited) + } + + @Test + fun aenderungen_in_einer_dokumentation_beruehren_eine_andere_nicht() = runDbTest { + env.insertPatient() + val first = repo.createEmpty("patient-1") + val second = repo.createEmpty("patient-1") + + repo.setChecked(first.blocks.first().firstPlainItem().checkItemStateId, true) + + assertTrue(repo.getFullDocumentation(second.id)!!.blocks.none { it.isEdited }) + } + + // --- Fotos --------------------------------------------------------------------------- + + @Test + fun addPhoto_vergibt_fortlaufenden_orderIndex_und_speichert_den_storage_dateinamen() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val uri1 = Uri.parse("content://test/1") + val uri2 = Uri.parse("content://test/2") + + val p1 = repo.addPhoto(doc.id, uri1) + val p2 = repo.addPhoto(doc.id, uri2) + + assertEquals(0, p1.orderIndex) + assertEquals(1, p2.orderIndex) + assertEquals(listOf(doc.id to uri1, doc.id to uri2), env.photoStorage.imported) + val photos = repo.getFullDocumentation(doc.id)!!.photos + assertEquals(listOf(p1.fileName, p2.fileName), photos.map { it.fileName }) + assertEquals(listOf(0, 1), photos.map { it.orderIndex }) + } + + @Test + fun removePhoto_loescht_db_zeile_und_datei_und_vergibt_orderIndex_nicht_neu() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val p1 = repo.addPhoto(doc.id, Uri.parse("content://test/1")) + val p2 = repo.addPhoto(doc.id, Uri.parse("content://test/2")) + + repo.removePhoto(p1.id) + + assertEquals(listOf(p2.id), repo.getFullDocumentation(doc.id)!!.photos.map { it.id }) + assertEquals(listOf(doc.id to p1.fileName), env.photoStorage.deleted) + val p3 = repo.addPhoto(doc.id, Uri.parse("content://test/3")) + assertEquals(2, p3.orderIndex) + } + + @Test + fun removePhoto_mit_unbekannter_id_tut_nichts() = runDbTest { + env.insertPatient() + repo.createEmpty("patient-1") + + repo.removePhoto("unbekannt") + + assertTrue(env.photoStorage.deleted.isEmpty()) + } + + @Test + fun removePhoto_entfernt_die_db_zeile_auch_wenn_die_datei_nicht_geloescht_werden_kann() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val photo = repo.addPhoto(doc.id, Uri.parse("content://test/1")) + env.photoStorage.failOnDelete = true + + repo.removePhoto(photo.id) + + assertTrue(repo.getFullDocumentation(doc.id)!!.photos.isEmpty()) + } + + // --- Signaturen ---------------------------------------------------------------------- + + @Test + fun saveSignature_trimmt_den_namen_und_vergibt_fortlaufenden_orderIndex() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + + val first = repo.saveSignature(doc.id, " Anna Schmidt ", signatureBitmap()) + val second = repo.saveSignature(doc.id, "Bernd Beispiel", signatureBitmap()) + + assertEquals("Anna Schmidt", first.signerName) + assertEquals(0, first.orderIndex) + assertEquals(1, second.orderIndex) + val signatures = repo.getFullDocumentation(doc.id)!!.signatures + assertEquals(listOf("Anna Schmidt", "Bernd Beispiel"), signatures.map { it.signerName }) + assertEquals(listOf(first.fileName, second.fileName), signatures.map { it.fileName }) + assertEquals(listOf(doc.id to first.fileName, doc.id to second.fileName), env.signatureStorage.saved) + } + + @Test + fun saveSignature_aktualisiert_das_wundversorger_register() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + + repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap()) + + assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name) + assertEquals(1, env.woundCarerSignatureStorage.saved.size) + } + + @Test + fun saveSignature_mit_leerem_namen_wirft_und_hinterlaesst_keine_datei() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + + expectThrows { repo.saveSignature(doc.id, " ", signatureBitmap()) } + + assertTrue(env.signatureStorage.saved.isEmpty()) + assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty()) + assertNull(env.woundCarerRepository.getMostRecentlyUsed()) + } + + @Test + fun saveSignature_mit_leerer_zeichnung_wirft_und_hinterlaesst_keine_datei() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + + expectThrows { repo.saveSignature(doc.id, "Anna Schmidt", blankBitmap()) } + + assertTrue(env.signatureStorage.saved.isEmpty()) + assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty()) + assertNull(env.woundCarerRepository.getMostRecentlyUsed()) + } + + @Test + fun removeSignature_loescht_db_zeile_und_datei() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap()) + + repo.removeSignature(signature.id) + + assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty()) + assertEquals(listOf(doc.id to signature.fileName), env.signatureStorage.deleted) + } + + @Test + fun removeSignature_mit_unbekannter_id_tut_nichts_und_toleriert_storage_fehler() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + + repo.removeSignature("unbekannt") + assertTrue(env.signatureStorage.deleted.isEmpty()) + + val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap()) + env.signatureStorage.failOnDelete = true + repo.removeSignature(signature.id) + + assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty()) + } + + // --- Löschen ------------------------------------------------------------------------- + + @Test + fun delete_entfernt_dokumentation_samt_bloecken_fotos_und_signaturen_sowie_dateiordner() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + repo.addPhoto(doc.id, Uri.parse("content://test/1")) + repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap()) + val blockStateIds = doc.blocks.map { it.blockStateId } + + repo.delete(doc.id) + + assertNull(repo.getFullDocumentation(doc.id)) + assertTrue(env.db.blockStateDao().getForDocumentation(doc.id).isEmpty()) + assertTrue(env.db.photoDao().getForDocumentation(doc.id).isEmpty()) + assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty()) + for (blockStateId in blockStateIds) { + assertTrue(env.db.checkItemStateDao().getForBlockState(blockStateId).isEmpty()) + } + assertEquals(listOf(doc.id), env.photoStorage.deletedAll) + assertEquals(listOf(doc.id), env.signatureStorage.deletedAll) + assertNotNull("Der Patient darf nicht mitgelöscht werden", env.patientRepository.getById("patient-1")) + } + + @Test + fun delete_beruehrt_das_wundversorger_register_nicht() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap()) + + repo.delete(doc.id) + + assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name) + assertTrue(env.woundCarerSignatureStorage.deletedAll.isEmpty()) + assertTrue(env.woundCarerSignatureStorage.deleted.isEmpty()) + } + + @Test + fun delete_mit_unbekannter_id_tut_nichts() = runDbTest { + repo.delete("unbekannt") + + assertTrue(env.photoStorage.deletedAll.isEmpty()) + assertTrue(env.signatureStorage.deletedAll.isEmpty()) + } + + @Test + fun delete_entfernt_die_dokumentation_auch_wenn_dateiordner_nicht_geloescht_werden_koennen() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + env.photoStorage.failOnDeleteAll = true + env.signatureStorage.failOnDeleteAll = true + + repo.delete(doc.id) + + assertNull(repo.getFullDocumentation(doc.id)) + } + + // --- PDF-Export ---------------------------------------------------------------------- + + @Test + fun exportPdf_fuer_unbekannte_dokumentation_liefert_fachlichen_fehler_ohne_exporter_aufruf() = runDbTest { + val result = repo.exportPdf("unbekannt") + + assertEquals(PdfExportResult.Failure(PdfExportError.DocumentationNotFound), result) + assertTrue(env.pdfExporter.models.isEmpty()) + } + + @Test + fun exportPdf_uebergibt_nur_bearbeitete_bloecke_und_den_schemakonformen_dateinamen() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val block = doc.blocks.first() + val item = block.firstPlainItem() + repo.setChecked(item.checkItemStateId, true) + + val result = repo.exportPdf(doc.id) + + assertTrue(result is PdfExportResult.Success) + val model = env.pdfExporter.models.single() + assertEquals(doc.id, model.documentationId) + assertEquals(listOf(block.title), model.blocks.map { it.title }) + assertTrue(model.blocks.single().items.any { it.label == item.label }) + val createdDate = doc.createdAt.atZone(ZoneId.systemDefault()).toLocalDate() + assertEquals("${createdDate}_Max_Mustermann_1980-03-14.pdf", model.fileName) + } + + @Test + fun exportPdf_verwendet_den_snapshot_und_nicht_den_aktuellen_patientendatensatz() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val patientDao = env.db.patientDao() + patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau", city = "Neustadt")) + + repo.exportPdf(doc.id) + + val model = env.pdfExporter.models.single() + assertEquals("Max Mustermann", model.patientFullName) + assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress) + } + + @Test + fun exportPdf_reicht_das_exporter_ergebnis_unveraendert_durch() = runDbTest { + env.insertPatient() + val doc = repo.createEmpty("patient-1") + val failure = PdfExportResult.Failure(PdfExportError.WriteFailed("Speicher voll")) + env.pdfExporter.result = { failure } + + assertEquals(failure, repo.exportPdf(doc.id)) + } + + @Test + fun convertPdfToImages_delegiert_an_den_converter() = runDbTest { + val pdf = File("/fake/pdfs/test.pdf") + env.pdfToImagesConverter.result = PdfToImagesResult.Failure.FileNotFound + + val result = repo.convertPdfToImages("doc-1", pdf) + + assertEquals(PdfToImagesResult.Failure.FileNotFound, result) + assertEquals(listOf("doc-1" to pdf), env.pdfToImagesConverter.calls) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt new file mode 100644 index 0000000..c4e4468 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt @@ -0,0 +1,222 @@ +package de.medapp.wunddoku.data.repository + +import android.database.sqlite.SQLiteConstraintException +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.medapp.wunddoku.domain.model.Patient +import de.medapp.wunddoku.testutil.TestEnvironment +import de.medapp.wunddoku.testutil.expectThrows +import de.medapp.wunddoku.testutil.runDbTest +import kotlinx.coroutines.flow.first +import org.junit.After +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.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Instant +import java.time.LocalDate +import java.util.UUID + +/** Repository-Tests für [PatientRepository] gegen eine echte In-Memory-Room-Datenbank. */ +@RunWith(AndroidJUnit4::class) +class PatientRepositoryTest { + + private lateinit var env: TestEnvironment + private val repo get() = env.patientRepository + + @Before + fun setUp() { + env = TestEnvironment() + } + + @After + fun tearDown() { + env.close() + } + + private fun patient( + id: String = "p-1", + firstName: String = "Max", + lastName: String = "Mustermann", + birthDate: LocalDate = LocalDate.of(1980, 3, 14), + ) = Patient( + id = id, + firstName = firstName, + lastName = lastName, + birthDate = birthDate, + street = "Musterstraße 1", + postalCode = "12345", + city = "Musterstadt", + phone1 = "0123 456789", + phone2 = "0987 654321", + phone3 = null, + createdAt = Instant.EPOCH, + updatedAt = Instant.EPOCH, + ) + + // --- upsert / getById ---------------------------------------------------------------- + + @Test + fun upsert_legt_neuen_patienten_an_und_setzt_zeitstempel_zentral() = runDbTest { + val before = Instant.now().toEpochMilli() + + val saved = repo.upsert(patient()) + + assertEquals(saved.createdAt, saved.updatedAt) + assertTrue(saved.createdAt.toEpochMilli() >= before) + val loaded = repo.getById("p-1")!! + assertEquals("Max", loaded.firstName) + assertEquals("Mustermann", loaded.lastName) + assertEquals(LocalDate.of(1980, 3, 14), loaded.birthDate) + assertEquals("Musterstraße 1", loaded.street) + assertEquals("12345", loaded.postalCode) + assertEquals("Musterstadt", loaded.city) + assertEquals("0123 456789", loaded.phone1) + assertEquals("0987 654321", loaded.phone2) + assertNull(loaded.phone3) + assertEquals(saved.createdAt, loaded.createdAt) + } + + @Test + fun upsert_aktualisiert_bestehenden_patienten_und_behaelt_createdAt() = runDbTest { + val first = repo.upsert(patient()) + + // createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper). + val second = repo.upsert(patient(lastName = "Musterfrau")) + + assertEquals(first.createdAt, second.createdAt) + assertTrue(second.updatedAt >= first.updatedAt) + val loaded = repo.getById("p-1")!! + assertEquals("Musterfrau", loaded.lastName) + assertEquals(first.createdAt, loaded.createdAt) + } + + @Test + fun upsert_erzeugt_bei_unterschiedlichen_ids_getrennte_datensaetze() = runDbTest { + repo.upsert(patient(id = "p-1")) + repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau")) + + assertEquals("Max", repo.getById("p-1")!!.firstName) + assertEquals("Erika", repo.getById("p-2")!!.firstName) + } + + @Test + fun getById_liefert_null_fuer_unbekannte_id() = runDbTest { + assertNull(repo.getById("unbekannt")) + } + + @Test + fun newId_liefert_gueltige_und_eindeutige_uuids() { + val a = repo.newId() + val b = repo.newId() + + assertEquals(a, UUID.fromString(a).toString()) + assertTrue(a != b) + } + + // --- findExisting -------------------------------------------------------------------- + + @Test + fun findExisting_trifft_nur_bei_gleichem_namen_und_geburtsdatum() = runDbTest { + repo.upsert(patient()) + + assertEquals("p-1", repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 14))?.id) + assertNull(repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 15))) + assertNull(repo.findExisting("Moritz", "Mustermann", LocalDate.of(1980, 3, 14))) + assertNull(repo.findExisting("Max", "Musterfrau", LocalDate.of(1980, 3, 14))) + } + + // --- Dokumentationen / Löschen ------------------------------------------------------- + + @Test + fun hasDocumentations_ist_erst_nach_dem_anlegen_einer_dokumentation_true() = runDbTest { + repo.upsert(patient()) + assertFalse(repo.hasDocumentations("p-1")) + + env.documentationRepository.createEmpty("p-1") + + assertTrue(repo.hasDocumentations("p-1")) + } + + @Test + fun delete_entfernt_patienten_ohne_dokumentation() = runDbTest { + repo.upsert(patient()) + + repo.delete("p-1") + + assertNull(repo.getById("p-1")) + } + + @Test + fun delete_wird_bei_vorhandener_dokumentation_von_der_datenbank_verhindert() = runDbTest { + repo.upsert(patient()) + val doc = env.documentationRepository.createEmpty("p-1") + + expectThrows { repo.delete("p-1") } + + assertNotNull(repo.getById("p-1")) + assertNotNull(env.documentationRepository.getFullDocumentation(doc.id)) + } + + @Test + fun delete_mit_unbekannter_id_tut_nichts() = runDbTest { + repo.upsert(patient()) + + repo.delete("unbekannt") + + assertNotNull(repo.getById("p-1")) + } + + // --- Liste / Suche ------------------------------------------------------------------- + + @Test + fun observeAll_sortiert_nach_nachname_dann_vorname() = runDbTest { + repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Müller")) + repo.upsert(patient(id = "p-2", firstName = "Anna", lastName = "Müller")) + repo.upsert(patient(id = "p-3", firstName = "Zoe", lastName = "Adler")) + + val list = repo.observeAll().first() + + assertEquals(listOf("p-3", "p-2", "p-1"), list.map { it.id }) + assertEquals("Zoe Adler", list.first().fullName) + } + + @Test + fun observeAll_zeigt_datum_der_letzten_dokumentation_oder_null() = runDbTest { + repo.upsert(patient(id = "p-1")) + repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau")) + val older = env.documentationRepository.createEmpty("p-1") + val newer = env.documentationRepository.createEmpty("p-1") + env.setCreatedAt(older.id, 1_000L) + env.setCreatedAt(newer.id, 2_000L) + + val byId = repo.observeAll().first().associateBy { it.id } + + assertEquals(Instant.ofEpochMilli(2_000L), byId.getValue("p-1").lastDocumentationAt) + assertNull(byId.getValue("p-2").lastDocumentationAt) + } + + @Test + fun search_findet_ueber_nachname_vorname_und_geburtsdatum() = runDbTest { + repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Mustermann")) + repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau", birthDate = LocalDate.of(1975, 12, 1))) + + // Beide Nachnamen beginnen mit "Muster": sortiert nach Nachname -> Musterfrau vor Mustermann. + assertEquals(listOf("p-2", "p-1"), repo.search("Muster").first().map { it.id }) + assertEquals(listOf("p-1"), repo.search("Max").first().map { it.id }) + assertEquals(listOf("p-2"), repo.search("Erika").first().map { it.id }) + assertEquals(listOf("p-2"), repo.search("1975-12").first().map { it.id }) + assertTrue(repo.search("Zzz").first().isEmpty()) + } + + @Test + fun search_ist_unabhaengig_von_gross_und_kleinschreibung() = runDbTest { + repo.upsert(patient()) + + assertEquals(listOf("p-1"), repo.search("mustermann").first().map { it.id }) + assertEquals(listOf("p-1"), repo.search("MAX").first().map { it.id }) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt new file mode 100644 index 0000000..fac990e --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt @@ -0,0 +1,156 @@ +package de.medapp.wunddoku.data.repository + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.medapp.wunddoku.testutil.TestEnvironment +import de.medapp.wunddoku.testutil.runDbTest +import de.medapp.wunddoku.testutil.signatureBitmap +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** Repository-Tests für das Wundversorger-Register ([WoundCarerRepository]). */ +@RunWith(AndroidJUnit4::class) +class WoundCarerRepositoryTest { + + private lateinit var env: TestEnvironment + private val repo get() = env.woundCarerRepository + private val storage get() = env.woundCarerSignatureStorage + + @Before + fun setUp() { + env = TestEnvironment() + } + + @After + fun tearDown() { + env.close() + } + + /** updatedAt basiert auf System.currentTimeMillis() - kurze Pause für eindeutige Reihenfolge. */ + private fun tick() = Thread.sleep(5) + + @Test + fun getMostRecentlyUsed_ist_null_solange_nie_eine_signatur_gespeichert_wurde() = runDbTest { + assertNull(repo.getMostRecentlyUsed()) + } + + @Test + fun recordSignatureUsage_legt_neuen_eintrag_mit_eigener_signaturdatei_an() = runDbTest { + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + + val carer = repo.getMostRecentlyUsed()!! + assertEquals("Anna Schmidt", carer.name) + assertEquals(1, storage.saved.size) + assertEquals(carer.id to carer.signatureFileName, storage.saved.single()) + } + + @Test + fun recordSignatureUsage_aktualisiert_bekannten_namen_case_insensitiv_und_ersetzt_die_signatur() = runDbTest { + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + val first = repo.getMostRecentlyUsed()!! + tick() + + repo.recordSignatureUsage("anna schmidt", signatureBitmap()) + + val second = repo.getMostRecentlyUsed()!! + assertEquals(first.id, second.id) + assertEquals("anna schmidt", second.name) // zuletzt getippte Schreibweise + assertNotEquals(first.signatureFileName, second.signatureFileName) + assertTrue(second.updatedAt >= first.updatedAt) + assertEquals(1, repo.search("a").size) + // Alte Signaturdatei wurde entfernt, genau eine Datei bleibt zugeordnet. + assertEquals(listOf(first.id to first.signatureFileName), storage.deleted) + } + + @Test + fun recordSignatureUsage_ignoriert_fehler_beim_loeschen_der_alten_signaturdatei() = runDbTest { + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + storage.failOnDelete = true + tick() + + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + + assertEquals(1, repo.search("Anna").size) + } + + @Test + fun getMostRecentlyUsed_liefert_den_zuletzt_aktualisierten_wundversorger() = runDbTest { + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + tick() + repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap()) + assertEquals("Bernd Beispiel", repo.getMostRecentlyUsed()?.name) + + tick() + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + + assertEquals("Anna Schmidt", repo.getMostRecentlyUsed()?.name) + } + + // --- search -------------------------------------------------------------------------- + + @Test + fun search_liefert_praefix_treffer_case_insensitiv_neueste_zuerst() = runDbTest { + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + tick() + repo.recordSignatureUsage("Andreas Müller", signatureBitmap()) + tick() + repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap()) + + val result = repo.search("an") + + assertEquals(listOf("Andreas Müller", "Anna Schmidt"), result.map { it.name }) + } + + @Test + fun search_trimmt_die_eingabe_und_liefert_bei_leerer_eingabe_nichts() = runDbTest { + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + + assertEquals(listOf("Anna Schmidt"), repo.search(" Ann ").map { it.name }) + assertTrue(repo.search("").isEmpty()) + assertTrue(repo.search(" ").isEmpty()) + } + + @Test + fun search_ist_ein_praefix_und_kein_teilstring_abgleich() = runDbTest { + repo.recordSignatureUsage("Anna Schmidt", signatureBitmap()) + + assertTrue(repo.search("Schmidt").isEmpty()) + } + + @Test + fun search_beachtet_das_limit() = runDbTest { + for (name in listOf("Anna A", "Anna B", "Anna C", "Anna D")) { + repo.recordSignatureUsage(name, signatureBitmap()) + tick() + } + + assertEquals(2, repo.search("Anna", limit = 2).size) + assertEquals(4, repo.search("Anna").size) // Standardlimit 5 deckt alle vier ab + assertEquals(listOf("Anna D", "Anna C"), repo.search("Anna", limit = 2).map { it.name }) + } + + // --- loadSignatureBitmap ------------------------------------------------------------- + + @Test + fun loadSignatureBitmap_delegiert_an_den_wundversorger_speicher() = runDbTest { + val bitmap = signatureBitmap() + storage.decodeResult = bitmap + + val result = repo.loadSignatureBitmap("carer-1", "sig.png") + + assertEquals(bitmap, result) + assertEquals(listOf("carer-1" to "sig.png"), storage.decodeCalls) + } + + @Test + fun loadSignatureBitmap_liefert_null_wenn_die_datei_fehlt() = runDbTest { + storage.decodeResult = null + + assertNull(repo.loadSignatureBitmap("carer-1", "fehlt.png")) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt new file mode 100644 index 0000000..f3d0726 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt @@ -0,0 +1,138 @@ +package de.medapp.wunddoku.testutil + +import android.graphics.Bitmap +import android.net.Uri +import de.medapp.wunddoku.media.PhotoStorage +import de.medapp.wunddoku.pdf.DocumentationPdfExporter +import de.medapp.wunddoku.pdf.PdfExportModel +import de.medapp.wunddoku.pdf.PdfExportResult +import de.medapp.wunddoku.pdf.PdfToImagesConverter +import de.medapp.wunddoku.pdf.PdfToImagesResult +import de.medapp.wunddoku.signature.SignatureStorage +import java.io.File +import java.io.IOException + +/** Aufgezeichneter Aufruf von [PhotoStorage.duplicate]. */ +data class DuplicateCall( + val sourceDocumentationId: String, + val sourceFileName: String, + val targetDocumentationId: String, +) + +/** + * In-Memory-Fake für [PhotoStorage]: schreibt keine Dateien, zeichnet aber alle Aufrufe auf und + * vergibt eindeutige Dateinamen. Fehler lassen sich gezielt auslösen. + */ +class FakePhotoStorage : PhotoStorage { + val imported = mutableListOf>() + val duplicated = mutableListOf() + val deleted = mutableListOf>() + val deletedAll = mutableListOf() + + var failOnDelete = false + var failOnDeleteAll = false + + /** 1-basierte Nummer des [duplicate]-Aufrufs, der mit einer [IOException] fehlschlagen soll. */ + var failOnDuplicateCall: Int? = null + + private var counter = 0 + + override suspend fun importFrom(documentationId: String, sourceUri: Uri): String { + imported += documentationId to sourceUri + return "imported_${++counter}.jpg" + } + + override suspend fun duplicate( + sourceDocumentationId: String, + sourceFileName: String, + targetDocumentationId: String, + ): String { + duplicated += DuplicateCall(sourceDocumentationId, sourceFileName, targetDocumentationId) + if (failOnDuplicateCall == duplicated.size) throw IOException("Kopieren fehlgeschlagen (Test)") + return "copy_${++counter}.jpg" + } + + override suspend fun delete(documentationId: String, fileName: String) { + if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)") + deleted += documentationId to fileName + } + + override suspend fun deleteAll(documentationId: String) { + if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)") + deletedAll += documentationId + } + + override fun resolve(documentationId: String, fileName: String): File = + File("/fake/photos/$documentationId/$fileName") + + override suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? = null + + override suspend fun decodeFitted( + documentationId: String, + fileName: String, + maxWidthPx: Int, + maxHeightPx: Int, + ): Bitmap? = null +} + +/** In-Memory-Fake für [SignatureStorage] (analog [FakePhotoStorage]). */ +class FakeSignatureStorage : SignatureStorage { + val saved = mutableListOf>() // key (documentationId bzw. woundCarerId) -> fileName + val deleted = mutableListOf>() + val deletedAll = mutableListOf() + val decodeCalls = mutableListOf>() + + var failOnDelete = false + var failOnDeleteAll = false + var decodeResult: Bitmap? = null + + private var counter = 0 + + override suspend fun save(documentationId: String, bitmap: Bitmap): String { + val fileName = "sig_${++counter}.png" + saved += documentationId to fileName + return fileName + } + + override suspend fun delete(documentationId: String, fileName: String) { + if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)") + deleted += documentationId to fileName + } + + override suspend fun deleteAll(documentationId: String) { + if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)") + deletedAll += documentationId + } + + override fun resolve(documentationId: String, fileName: String): File = + File("/fake/signatures/$documentationId/$fileName") + + override suspend fun decode(documentationId: String, fileName: String): Bitmap? { + decodeCalls += documentationId to fileName + return decodeResult + } +} + +/** Zeichnet die an den Exporter übergebenen Modelle auf und liefert ein konfigurierbares Ergebnis. */ +class FakePdfExporter : DocumentationPdfExporter { + val models = mutableListOf() + var result: ((PdfExportModel) -> PdfExportResult) = { model -> + PdfExportResult.Success(File("/fake/pdfs/${model.fileName}")) + } + + override suspend fun export(model: PdfExportModel): PdfExportResult { + models += model + return result(model) + } +} + +/** Zeichnet Aufrufe auf und liefert ein konfigurierbares Ergebnis. */ +class FakePdfToImagesConverter : PdfToImagesConverter { + val calls = mutableListOf>() + var result: PdfToImagesResult = PdfToImagesResult.Success(emptyList()) + + override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult { + calls += documentationId to pdfFile + return result + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt new file mode 100644 index 0000000..2ac7830 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt @@ -0,0 +1,133 @@ +package de.medapp.wunddoku.testutil + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import de.medapp.wunddoku.data.database.AppDatabase +import de.medapp.wunddoku.data.entity.PatientEntity +import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.data.repository.PatientRepository +import de.medapp.wunddoku.data.repository.WoundCarerRepository +import de.medapp.wunddoku.domain.model.CheckItem +import de.medapp.wunddoku.domain.model.DocumentationBlock +import de.medapp.wunddoku.domain.model.DocumentationStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking + +/** + * Gemeinsame Testumgebung für alle Instrumented-Tests: echte Room-Datenbank im Arbeitsspeicher + * (gleiche Entities/DAOs/Foreign Keys wie in Produktion), aber Fakes für Dateispeicher und + * PDF-Erzeugung. Jeder Test erzeugt eine eigene Instanz und ruft am Ende [close] auf. + */ +class TestEnvironment { + + val db: AppDatabase = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).build() + + val photoStorage = FakePhotoStorage() + val signatureStorage = FakeSignatureStorage() + val woundCarerSignatureStorage = FakeSignatureStorage() + val pdfExporter = FakePdfExporter() + val pdfToImagesConverter = FakePdfToImagesConverter() + + val patientRepository = PatientRepository(patientDao = db.patientDao()) + + val woundCarerRepository = WoundCarerRepository( + woundCarerDao = db.woundCarerDao(), + woundCarerSignatureStorage = woundCarerSignatureStorage, + ) + + val documentationRepository = DocumentationRepository( + documentationDao = db.documentationDao(), + blockStateDao = db.blockStateDao(), + checkItemStateDao = db.checkItemStateDao(), + photoDao = db.photoDao(), + signatureDao = db.signatureDao(), + patientDao = db.patientDao(), + photoStorage = photoStorage, + signatureStorage = signatureStorage, + woundCarerRepository = woundCarerRepository, + pdfExporter = pdfExporter, + pdfToImagesConverter = pdfToImagesConverter, + db = db, + ) + + fun close() = db.close() + + /** Legt direkt über das DAO einen Patienten an (ohne Repository-Logik). */ + suspend fun insertPatient( + id: String = "patient-1", + firstName: String = "Max", + lastName: String = "Mustermann", + birthDate: String = "1980-03-14", + street: String = "Musterstraße 1", + postalCode: String = "12345", + city: String = "Musterstadt", + ): PatientEntity { + val entity = PatientEntity( + id = id, + firstName = firstName, + lastName = lastName, + birthDate = birthDate, + street = street, + postalCode = postalCode, + city = city, + phone1 = "0123 456789", + phone2 = null, + phone3 = null, + createdAt = 1_000L, + updatedAt = 1_000L, + ) + db.patientDao().insert(entity) + return entity + } + + /** Setzt createdAt einer Dokumentation gezielt (vermeidet zeitabhängige Tests). */ + suspend fun setCreatedAt(documentationId: String, createdAt: Long) { + val dao = db.documentationDao() + dao.update(dao.getById(documentationId)!!.copy(createdAt = createdAt)) + } + + suspend fun setStatus(documentationId: String, status: DocumentationStatus) { + val dao = db.documentationDao() + dao.update(dao.getById(documentationId)!!.copy(status = status)) + } +} + +/** Führt einen Test mit Suspend-Aufrufen aus; Rückgabetyp Unit, damit JUnit die Methode akzeptiert. */ +fun runDbTest(block: suspend CoroutineScope.() -> Unit) { + runBlocking { block() } +} + +/** Erstes Item eines Blocks, das eine normale Checkbox ist (kein Freitext, hint oder strukturierte Eingabe). */ +fun DocumentationBlock.firstPlainItem(): CheckItem = + items.first { !it.isFreeText && !it.isStaticHint && it.inputSpec.isEmpty() } + +/** Weißes Bitmap ohne jeden Strich - gilt als "leere Signatur". */ +fun blankBitmap(): Bitmap = + Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) } + +/** Weißes Bitmap mit einem schwarzen Strich - gilt als gültige Signatur. */ +fun signatureBitmap(): Bitmap = blankBitmap().also { bitmap -> + val paint = Paint().apply { + color = Color.BLACK + strokeWidth = 3f + } + Canvas(bitmap).drawLine(2f, 2f, 38f, 18f, paint) +} + +/** Erwartet, dass [block] eine Exception vom Typ [T] wirft, und liefert sie zurück. */ +inline fun expectThrows(block: () -> Unit): T { + try { + block() + } catch (e: Throwable) { + if (e is T) return e + throw e + } + throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen") +} diff --git a/app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt b/app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt new file mode 100644 index 0000000..8a07853 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt @@ -0,0 +1,151 @@ +package de.medapp.wunddoku.domain.usecase + +import de.medapp.wunddoku.domain.model.CheckItem +import de.medapp.wunddoku.domain.model.DocumentationBlock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Blockstatus-Tests: Wann gilt ein Dokumentationsblock als "bearbeitet"? + * Diese Regel steuert Fortschrittsanzeige UND PDF-Export und muss deshalb exakt stimmen. + */ +class BlockEditedPolicyTest { + + private var idCounter = 0 + + private fun item( + checked: Boolean, + isFreeText: Boolean = false, + freeTextValue: String = "", + isStaticHint: Boolean = false, + inputSpec: String = "", + ): CheckItem { + idCounter++ + return CheckItem( + checkItemStateId = "state-$idCounter", + definitionId = "def-$idCounter", + label = "Eintrag $idCounter", + order = idCounter, + checked = checked, + isFreeText = isFreeText, + freeTextValue = freeTextValue, + isStaticHint = isStaticHint, + inputSpec = inputSpec, + ) + } + + private fun block(vararg items: CheckItem) = DocumentationBlock( + blockStateId = "block-state", + definitionId = "block-def", + title = "Testblock", + order = 1, + items = items.toList(), + ) + + // --- nicht bearbeitet ---------------------------------------------------------------- + + @Test + fun block_ohne_eintraege_ist_nicht_bearbeitet() { + assertFalse(BlockEditedPolicy.isEdited(block())) + } + + @Test + fun block_ohne_angehakten_eintrag_ist_nicht_bearbeitet() { + val b = block(item(checked = false), item(checked = false), item(checked = false, isFreeText = true)) + assertFalse(BlockEditedPolicy.isEdited(b)) + } + + @Test + fun nicht_angehakter_freitext_mit_text_zaehlt_nicht_als_bearbeitung() { + // Das Freitextfeld ist in der UI gesperrt, solange die Checkbox nicht angehakt ist. + val b = block(item(checked = false, isFreeText = true, freeTextValue = "Text ohne Haken")) + assertFalse(BlockEditedPolicy.isEdited(b)) + } + + @Test + fun nur_hinweistexte_machen_einen_block_nicht_bearbeitet() { + // Hinweistexte sind technisch immer checked = true, aber keine Nutzereingabe. + val b = block( + item(checked = true, isStaticHint = true), + item(checked = true, isStaticHint = true), + ) + assertFalse(BlockEditedPolicy.isEdited(b)) + } + + @Test + fun hinweistexte_plus_nicht_angehakte_eintraege_sind_nicht_bearbeitet() { + val b = block( + item(checked = true, isStaticHint = true), + item(checked = false), + item(checked = false, isFreeText = true), + ) + assertFalse(BlockEditedPolicy.isEdited(b)) + } + + // --- bearbeitet ---------------------------------------------------------------------- + + @Test + fun ein_angehakter_normaler_eintrag_macht_den_block_bearbeitet() { + assertTrue(BlockEditedPolicy.isEdited(block(item(checked = true)))) + } + + @Test + fun angehakter_freitext_zaehlt_auch_ohne_eingegebenen_text() { + val b = block(item(checked = true, isFreeText = true, freeTextValue = "")) + assertTrue(BlockEditedPolicy.isEdited(b)) + } + + @Test + fun angehakter_freitext_mit_text_ist_bearbeitet() { + val b = block(item(checked = true, isFreeText = true, freeTextValue = "Rötung am Wundrand")) + assertTrue(BlockEditedPolicy.isEdited(b)) + } + + @Test + fun angehakter_eintrag_mit_strukturierter_eingabe_ist_bearbeitet() { + val b = block(item(checked = true, freeTextValue = "3", inputSpec = "spec")) + assertTrue(BlockEditedPolicy.isEdited(b)) + } + + @Test + fun hinweistext_wird_ignoriert_sobald_ein_echter_eintrag_angehakt_ist() { + val b = block( + item(checked = true, isStaticHint = true), + item(checked = true), + ) + assertTrue(BlockEditedPolicy.isEdited(b)) + } + + @Test + fun mehrere_angehakte_eintraege_sind_bearbeitet() { + val b = block(item(checked = true), item(checked = true), item(checked = false)) + assertTrue(BlockEditedPolicy.isEdited(b)) + } + + // --- Zusammenspiel mit DocumentationBlock.isEdited ------------------------------------ + + @Test + fun documentation_block_isEdited_delegiert_an_die_policy() { + val edited = block(item(checked = true)) + val untouched = block(item(checked = false), item(checked = true, isStaticHint = true)) + + assertEquals(BlockEditedPolicy.isEdited(edited), edited.isEdited) + assertEquals(BlockEditedPolicy.isEdited(untouched), untouched.isEdited) + assertTrue(edited.isEdited) + assertFalse(untouched.isEdited) + } + + @Test + fun isEdited_wird_aus_dem_aktuellen_inhalt_abgeleitet_und_nicht_gespeichert() { + val untouched = block(item(checked = false)) + assertFalse(untouched.isEdited) + + val nowChecked = untouched.copy(items = untouched.items.map { it.copy(checked = true) }) + assertTrue(nowChecked.isEdited) + + val uncheckedAgain = nowChecked.copy(items = nowChecked.items.map { it.copy(checked = false) }) + assertFalse(uncheckedAgain.isEdited) + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt b/app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt new file mode 100644 index 0000000..df3d5bd --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt @@ -0,0 +1,245 @@ +package de.medapp.wunddoku.pdf + +import de.medapp.wunddoku.domain.model.CheckItem +import de.medapp.wunddoku.domain.model.Documentation +import de.medapp.wunddoku.domain.model.DocumentationBlock +import de.medapp.wunddoku.domain.model.DocumentationStatus +import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot +import de.medapp.wunddoku.domain.model.Photo +import de.medapp.wunddoku.domain.model.Signature +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Blockstatus im PDF-Export: Nur bearbeitete Blöcke und nur angehakte Optionen gelangen ins PDF, + * alles wird aus den historischen Snapshots gebildet und stabil sortiert. + */ +class DocumentationToPdfExportModelMapperTest { + + private val snapshot = PatientStammdatenSnapshot( + firstName = "Max", + lastName = "Mustermann", + birthDate = LocalDate.of(1980, 3, 14), + street = "Musterstraße 1", + postalCode = "12345", + city = "Musterstadt", + ) + + // Mittag in der Systemzeitzone -> das lokale Datum ist zeitzonenunabhängig der 23.08.2026. + private val createdAt: Instant = + LocalDate.of(2026, 8, 23).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant() + + private fun item( + id: String, + order: Int, + checked: Boolean, + label: String = "Label $id", + isFreeText: Boolean = false, + freeTextValue: String = "", + isStaticHint: Boolean = false, + inputSpec: String = "", + ) = CheckItem( + checkItemStateId = "state-$id", + definitionId = id, + label = label, + order = order, + checked = checked, + isFreeText = isFreeText, + freeTextValue = freeTextValue, + isStaticHint = isStaticHint, + inputSpec = inputSpec, + ) + + private fun block(id: String, order: Int, title: String = "Titel $id", items: List) = + DocumentationBlock("state-$id", id, title, order, items) + + private fun documentation( + blocks: List, + photos: List = emptyList(), + signatures: List = emptyList(), + ) = Documentation( + id = "doc-1", + patientId = "patient-1", + patientSnapshot = snapshot, + status = DocumentationStatus.IN_PROGRESS, + createdAt = createdAt, + updatedAt = createdAt, + continuedFromDocumentationId = null, + catalogVersion = 4, + blocks = blocks, + photos = photos, + signatures = signatures, + ) + + // --- Blockfilter --------------------------------------------------------------------- + + @Test + fun nur_bearbeitete_bloecke_werden_exportiert() { + val doc = documentation( + listOf( + block("a", 1, items = listOf(item("a1", 1, checked = true))), + block("b", 2, items = listOf(item("b1", 1, checked = false))), + block("c", 3, items = listOf(item("c1", 1, checked = true))), + ) + ) + + val model = DocumentationToPdfExportModelMapper.map(doc) + + assertEquals(listOf("Titel a", "Titel c"), model.blocks.map { it.title }) + } + + @Test + fun block_mit_nur_hinweistexten_wird_nicht_exportiert() { + val doc = documentation( + listOf( + block( + "hint", 1, + items = listOf( + item("h1", 1, checked = true, isStaticHint = true), + item("h2", 2, checked = false), + ), + ), + ) + ) + + assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty()) + } + + @Test + fun dokumentation_ohne_bearbeiteten_block_ergibt_leere_blockliste() { + val doc = documentation(listOf(block("a", 1, items = listOf(item("a1", 1, checked = false))))) + assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty()) + } + + // --- Itemfilter ---------------------------------------------------------------------- + + @Test + fun nur_angehakte_optionen_erscheinen_im_pdf() { + val doc = documentation( + listOf( + block( + "a", 1, + items = listOf( + item("a1", 1, checked = true, label = "Ja"), + item("a2", 2, checked = false, label = "Nein"), + item("a3", 3, checked = true, label = "Auch ja"), + ), + ), + ) + ) + + val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items + + assertEquals(listOf("Ja", "Auch ja"), items.map { it.label }) + } + + @Test + fun hinweistexte_eines_bearbeiteten_blocks_bleiben_im_pdf_erhalten() { + val doc = documentation( + listOf( + block( + "a", 1, + items = listOf( + item("hint", 1, checked = true, label = "Reinigen mit:", isStaticHint = true), + item("a2", 2, checked = true, label = "Ringer-Lösung"), + ), + ), + ) + ) + + val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items + + assertEquals(listOf("Reinigen mit:", "Ringer-Lösung"), items.map { it.label }) + } + + @Test + fun freitext_und_strukturierte_eingabe_werden_unveraendert_durchgereicht() { + val doc = documentation( + listOf( + block( + "a", 1, + items = listOf( + item("f", 1, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "Rötung"), + item("s", 2, checked = true, label = "Maße", freeTextValue = "3|4", inputSpec = "SPEC"), + ), + ), + ) + ) + + val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items + + assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), items[0]) + assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1]) + } + + // --- Sortierung ---------------------------------------------------------------------- + + @Test + fun bloecke_und_optionen_werden_nach_order_sortiert() { + val doc = documentation( + listOf( + block("zweiter", 2, items = listOf(item("z1", 1, checked = true))), + block( + "erster", 1, + items = listOf( + item("e2", 2, checked = true, label = "B"), + item("e1", 1, checked = true, label = "A"), + ), + ), + ) + ) + + val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks + + assertEquals(listOf("Titel erster", "Titel zweiter"), blocks.map { it.title }) + assertEquals(listOf("A", "B"), blocks.first().items.map { it.label }) + } + + @Test + fun fotos_und_signaturen_werden_nach_orderIndex_sortiert() { + val photos = listOf( + Photo("p2", "doc-1", "zweites.jpg", 1, createdAt), + Photo("p1", "doc-1", "erstes.jpg", 0, createdAt), + ) + val signatures = listOf( + Signature("s2", "doc-1", "Berta", "b.png", 1, createdAt), + Signature("s1", "doc-1", "Anna", "a.png", 0, createdAt), + ) + + val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), photos, signatures)) + + assertEquals(listOf("erstes.jpg", "zweites.jpg"), model.photos.map { it.fileName }) + assertEquals(listOf("Anna", "Berta"), model.signatures.map { it.signerName }) + assertEquals(listOf("a.png", "b.png"), model.signatures.map { it.fileName }) + } + + @Test + fun alle_signaturen_werden_exportiert_ohne_obergrenze() { + val signatures = (0 until 5).map { Signature("s$it", "doc-1", "Person $it", "s$it.png", it, createdAt) } + val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), signatures = signatures)) + assertEquals(5, model.signatures.size) + } + + // --- Kopfdaten (Snapshot) ------------------------------------------------------------ + + @Test + fun kopfdaten_stammen_aus_dem_patienten_snapshot_der_dokumentation() { + val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList())) + + assertEquals("doc-1", model.documentationId) + assertEquals("Max Mustermann", model.patientFullName) + assertEquals(LocalDate.of(1980, 3, 14), model.patientBirthDate) + assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress) + assertEquals(LocalDate.of(2026, 8, 23), model.createdAt) + } + + @Test + fun dateiname_folgt_dem_schema_und_nutzt_snapshot_und_erstelldatum() { + val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList())) + assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", model.fileName) + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt b/app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt new file mode 100644 index 0000000..e9b6374 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt @@ -0,0 +1,175 @@ +package de.medapp.wunddoku.pdf + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * Reine JVM-Tests (keine Android-Abhängigkeit) für das Dateinamensschema + * `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`. + */ +class PdfFileNameGeneratorTest { + + private val created = LocalDate.of(2026, 8, 23) + private val birth = LocalDate.of(1980, 3, 14) + + private fun name( + first: String, + last: String, + createdAt: LocalDate = created, + birthDate: LocalDate = birth, + ): String = PdfFileNameGenerator.generate(createdAt, first, last, birthDate) + + // --- Grundschema --------------------------------------------------------------------- + + @Test + fun standardfall_folgt_dem_vereinbarten_schema() { + assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max", "Mustermann")) + } + + @Test + fun datumsteile_werden_mit_fuehrenden_nullen_im_iso_format_ausgegeben() { + val result = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 1, 5), birthDate = LocalDate.of(1999, 2, 3)) + assertEquals("2026-01-05_Max_Mustermann_1999-02-03.pdf", result) + } + + @Test + fun schaltjahr_geburtsdatum_wird_korrekt_ausgegeben() { + val result = name("Max", "Mustermann", birthDate = LocalDate.of(2000, 2, 29)) + assertEquals("2026-08-23_Max_Mustermann_2000-02-29.pdf", result) + } + + @Test + fun ergebnis_ist_deterministisch() { + assertEquals(name("Max", "Mustermann"), name("Max", "Mustermann")) + } + + @Test + fun verschiedene_erstelldaten_fuehren_zu_verschiedenen_dateinamen() { + val a = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 23)) + val b = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 24)) + assertNotEquals(a, b) + } + + // --- Umlaute / Diakritika ------------------------------------------------------------ + + @Test + fun deutsche_umlaute_werden_transliteriert_statt_entfernt() { + assertEquals("2026-08-23_Max_Mueller_1980-03-14.pdf", name("Max", "Müller")) + assertEquals("2026-08-23_Aenne_Oezdemir_1980-03-14.pdf", name("Änne", "Özdemir")) + assertEquals("2026-08-23_Uwe_Ueberall_1980-03-14.pdf", name("Uwe", "Überall")) + } + + @Test + fun eszett_wird_zu_doppel_s() { + assertEquals("2026-08-23_Max_Weiss_1980-03-14.pdf", name("Max", "Weiß")) + } + + @Test + fun mueller_und_mller_bleiben_unterscheidbar() { + assertNotEquals(name("Max", "Müller"), name("Max", "Mller")) + } + + @Test + fun andere_diakritika_werden_entfernt() { + assertEquals("2026-08-23_Jose_Celik_1980-03-14.pdf", name("José", "Çelik")) + } + + // --- Leerzeichen / Sonderzeichen ----------------------------------------------------- + + @Test + fun leerzeichen_werden_zu_unterstrichen() { + assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann")) + } + + @Test + fun mehrfaches_whitespace_wird_zu_einem_unterstrich_zusammengefasst() { + assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann")) + assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna\t\nMaria", "Mustermann")) + } + + @Test + fun mehrfache_unterstriche_werden_zusammengefasst() { + assertEquals("2026-08-23_Max_Muster_mann_1980-03-14.pdf", name("Max", "Muster__mann")) + } + + @Test + fun fuehrende_und_nachgestellte_trennzeichen_werden_entfernt() { + val expected = "2026-08-23_Max_Mustermann_1980-03-14.pdf" + assertEquals(expected, name(" Max ", "Mustermann")) + assertEquals(expected, name("_Max_", "Mustermann")) + assertEquals(expected, name("-Max-", "Mustermann")) + } + + @Test + fun bindestrich_im_doppelnamen_bleibt_erhalten() { + assertEquals("2026-08-23_Aenne-Marie_Meyer-Luedenscheidt_1980-03-14.pdf", name("Änne-Marie", "Meyer-Lüdenscheidt")) + } + + @Test + fun apostroph_und_punkt_werden_entfernt() { + assertEquals("2026-08-23_Sean_OBrien_1980-03-14.pdf", name("Sean", "O'Brien")) + assertEquals("2026-08-23_Max_Dr_Mustermann_1980-03-14.pdf", name("Max", "Dr. Mustermann")) + } + + // --- Sicherheit (Pfad-/Dateisystemzeichen) -------------------------------------------- + + @Test + fun pfadseparatoren_und_punkte_koennen_keinen_pfad_erzeugen() { + assertEquals( + "2026-08-23_Unbekannt_etcpasswd_1980-03-14.pdf", + name("../..", "../etc/passwd"), + ) + } + + @Test + fun fuer_dateinamen_ungueltige_zeichen_werden_entfernt() { + assertEquals( + "2026-08-23_MaxMuster_Mustermann_1980-03-14.pdf", + name("Max:Mus*ter?", "Mustermann"), + ) + assertEquals( + "2026-08-23_Max_Mustermann_1980-03-14.pdf", + name("Ma|\"", "Muster\\mann"), + ) + } + + @Test + fun emojis_werden_entfernt() { + assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max😀", "Mustermann")) + } + + @Test + fun dateiname_besteht_immer_nur_aus_sicheren_zeichen_und_endet_auf_pdf() { + val inputs = listOf( + "Max" to "Mustermann", + "Änne-Marie" to "Özdemir Weiß", + "José" to "O'Brien", + "../../x" to "a\\b/c", + " " to "", + "😀" to "???", + "Ma|\"" to "Muster\\mann", + ) + val allowed = Regex("^[A-Za-z0-9_.-]+$") + for ((first, last) in inputs) { + val result = name(first, last) + assertTrue("Unsicherer Dateiname: $result", allowed.matches(result)) + assertTrue("Falsche Endung: $result", result.endsWith(".pdf")) + } + } + + // --- Platzhalter für leere Namen ----------------------------------------------------- + + @Test + fun leerer_oder_blanker_name_wird_zu_unbekannt() { + assertEquals("2026-08-23_Unbekannt_Mustermann_1980-03-14.pdf", name("", "Mustermann")) + assertEquals("2026-08-23_Max_Unbekannt_1980-03-14.pdf", name("Max", " ")) + } + + @Test + fun name_nur_aus_ungueltigen_zeichen_wird_zu_unbekannt() { + assertEquals("2026-08-23_Unbekannt_Unbekannt_1980-03-14.pdf", name("???", "***")) + } +} From 26cc81388e4f5b583202a143111d11fca6e4e517 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Fri, 9 Oct 2026 10:01:32 +0200 Subject: [PATCH 2/3] Update PatientRepositoryTest.kt --- .../wunddoku/data/repository/PatientRepositoryTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt index c4e4468..093e157 100644 --- a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt @@ -77,7 +77,8 @@ class PatientRepositoryTest { assertEquals("0123 456789", loaded.phone1) assertEquals("0987 654321", loaded.phone2) assertNull(loaded.phone3) - assertEquals(saved.createdAt, loaded.createdAt) + // Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit. + assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli()) } @Test @@ -87,11 +88,11 @@ class PatientRepositoryTest { // createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper). val second = repo.upsert(patient(lastName = "Musterfrau")) - assertEquals(first.createdAt, second.createdAt) - assertTrue(second.updatedAt >= first.updatedAt) + assertEquals(first.createdAt.toEpochMilli(), second.createdAt.toEpochMilli()) + assertTrue(second.updatedAt.toEpochMilli() >= first.updatedAt.toEpochMilli()) val loaded = repo.getById("p-1")!! assertEquals("Musterfrau", loaded.lastName) - assertEquals(first.createdAt, loaded.createdAt) + assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli()) } @Test From 77f71973a761daaea1da67af8248ba6d2e3c13aa Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Fri, 9 Oct 2026 10:07:17 +0200 Subject: [PATCH 3/3] 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) } }