diff --git a/backend/pom.xml b/backend/pom.xml
index a5eddab8..81eacb23 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -65,6 +65,16 @@
org.springframework.boot
spring-boot-starter-jetty
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+ org.testcontainers
+ testcontainers-postgresql
+ test
+
org.springframework.boot
spring-boot-starter-actuator-test
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java b/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java
new file mode 100644
index 00000000..899c526c
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java
@@ -0,0 +1,25 @@
+package org.raddatz.familienarchiv;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.testcontainers.containers.PostgreSQLContainer;
+import software.amazon.awssdk.services.s3.S3Client;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
+@ActiveProfiles("test")
+@Import(PostgresContainerConfig.class)
+class ApplicationContextTest {
+
+ @MockitoBean
+ S3Client s3Client;
+
+ @Test
+ void contextLoads() {
+ // verifies that the Spring context starts successfully with all beans wired,
+ // Flyway migrations applied, and no configuration errors
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/PostgresContainerConfig.java b/backend/src/test/java/org/raddatz/familienarchiv/PostgresContainerConfig.java
new file mode 100644
index 00000000..20c0697e
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/PostgresContainerConfig.java
@@ -0,0 +1,16 @@
+package org.raddatz.familienarchiv;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean;
+import org.testcontainers.containers.PostgreSQLContainer;
+
+@TestConfiguration(proxyBeanMethods = false)
+public class PostgresContainerConfig {
+
+ @Bean
+ @ServiceConnection
+ PostgreSQLContainer> postgresContainer() {
+ return new PostgreSQLContainer<>("postgres:16-alpine");
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java
new file mode 100644
index 00000000..0a960117
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java
@@ -0,0 +1,150 @@
+package org.raddatz.familienarchiv.repository;
+
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.config.FlywayConfig;
+import org.raddatz.familienarchiv.model.Document;
+import org.raddatz.familienarchiv.model.DocumentStatus;
+import org.raddatz.familienarchiv.model.Person;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.context.annotation.Import;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Import({PostgresContainerConfig.class, FlywayConfig.class})
+class DocumentRepositoryTest {
+
+ @Autowired
+ private DocumentRepository documentRepository;
+
+ @Autowired
+ private PersonRepository personRepository;
+
+ // ─── save and findById ────────────────────────────────────────────────────
+
+ @Test
+ void save_persistsDocument_andFindByIdReturnsSameDocument() {
+ Document document = Document.builder()
+ .title("Testbrief")
+ .originalFilename("testbrief.pdf")
+ .status(DocumentStatus.PLACEHOLDER)
+ .build();
+
+ Document saved = documentRepository.save(document);
+ Optional found = documentRepository.findById(saved.getId());
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getTitle()).isEqualTo("Testbrief");
+ assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
+ }
+
+ // ─── findByStatus ─────────────────────────────────────────────────────────
+
+ @Test
+ void findByStatus_returnsOnlyDocumentsWithMatchingStatus() {
+ documentRepository.save(Document.builder()
+ .title("Placeholder Doc")
+ .originalFilename("placeholder.pdf")
+ .status(DocumentStatus.PLACEHOLDER)
+ .build());
+ documentRepository.save(Document.builder()
+ .title("Uploaded Doc")
+ .originalFilename("uploaded.pdf")
+ .status(DocumentStatus.UPLOADED)
+ .build());
+
+ List placeholders = documentRepository.findByStatus(DocumentStatus.PLACEHOLDER);
+
+ assertThat(placeholders).extracting(Document::getStatus)
+ .containsOnly(DocumentStatus.PLACEHOLDER);
+ }
+
+ // ─── findByOriginalFilename ───────────────────────────────────────────────
+
+ @Test
+ void findByOriginalFilename_returnsDocument_whenFilenameMatches() {
+ documentRepository.save(Document.builder()
+ .title("Omas Brief")
+ .originalFilename("omas_brief.pdf")
+ .status(DocumentStatus.PLACEHOLDER)
+ .build());
+
+ Optional found = documentRepository.findByOriginalFilename("omas_brief.pdf");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getTitle()).isEqualTo("Omas Brief");
+ }
+
+ @Test
+ void findByOriginalFilename_returnsEmpty_whenFilenameDoesNotExist() {
+ Optional found = documentRepository.findByOriginalFilename("does_not_exist.pdf");
+
+ assertThat(found).isEmpty();
+ }
+
+ // ─── existsByOriginalFilename ─────────────────────────────────────────────
+
+ @Test
+ void existsByOriginalFilename_returnsTrue_whenDocumentExists() {
+ documentRepository.save(Document.builder()
+ .title("Brief")
+ .originalFilename("brief.pdf")
+ .status(DocumentStatus.PLACEHOLDER)
+ .build());
+
+ assertThat(documentRepository.existsByOriginalFilename("brief.pdf")).isTrue();
+ }
+
+ @Test
+ void existsByOriginalFilename_returnsFalse_whenDocumentDoesNotExist() {
+ assertThat(documentRepository.existsByOriginalFilename("nonexistent.pdf")).isFalse();
+ }
+
+ // ─── findBySenderId ───────────────────────────────────────────────────────
+
+ @Test
+ void findBySenderId_returnsDocuments_whereSenderIdMatches() {
+ Person sender = personRepository.save(Person.builder()
+ .firstName("Hans")
+ .lastName("Müller")
+ .build());
+ documentRepository.save(Document.builder()
+ .title("Brief von Hans")
+ .originalFilename("brief_hans.pdf")
+ .status(DocumentStatus.UPLOADED)
+ .sender(sender)
+ .build());
+
+ List docs = documentRepository.findBySenderId(sender.getId());
+
+ assertThat(docs).hasSize(1);
+ assertThat(docs.get(0).getSender().getId()).isEqualTo(sender.getId());
+ }
+
+ // ─── countByMetadataCompleteFalse ─────────────────────────────────────────
+
+ @Test
+ void countByMetadataCompleteFalse_returnsNumberOfIncompleteDocuments() {
+ documentRepository.save(Document.builder()
+ .title("Incomplete")
+ .originalFilename("incomplete.pdf")
+ .status(DocumentStatus.PLACEHOLDER)
+ .metadataComplete(false)
+ .build());
+ documentRepository.save(Document.builder()
+ .title("Complete")
+ .originalFilename("complete.pdf")
+ .status(DocumentStatus.UPLOADED)
+ .metadataComplete(true)
+ .build());
+
+ assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java
new file mode 100644
index 00000000..8ed8fb09
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java
@@ -0,0 +1,136 @@
+package org.raddatz.familienarchiv.repository;
+
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.config.FlywayConfig;
+import org.raddatz.familienarchiv.model.Person;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.context.annotation.Import;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Import({PostgresContainerConfig.class, FlywayConfig.class})
+class PersonRepositoryTest {
+
+ @Autowired
+ private PersonRepository personRepository;
+
+ // ─── save and findById ────────────────────────────────────────────────────
+
+ @Test
+ void save_persistsPerson_andFindByIdReturnsSamePerson() {
+ Person person = Person.builder()
+ .firstName("Anna")
+ .lastName("Schmidt")
+ .build();
+
+ Person saved = personRepository.save(person);
+ Optional found = personRepository.findById(saved.getId());
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getFirstName()).isEqualTo("Anna");
+ assertThat(found.get().getLastName()).isEqualTo("Schmidt");
+ }
+
+ // ─── searchByName ─────────────────────────────────────────────────────────
+
+ @Test
+ void searchByName_findsByFirstName() {
+ personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
+ personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
+
+ List results = personRepository.searchByName("Hans");
+
+ assertThat(results).hasSize(1);
+ assertThat(results.get(0).getFirstName()).isEqualTo("Hans");
+ }
+
+ @Test
+ void searchByName_findsByLastName() {
+ personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
+ personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
+
+ List results = personRepository.searchByName("Schmidt");
+
+ assertThat(results).hasSize(1);
+ assertThat(results.get(0).getLastName()).isEqualTo("Schmidt");
+ }
+
+ @Test
+ void searchByName_isCaseInsensitive() {
+ personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
+
+ List results = personRepository.searchByName("hans");
+
+ assertThat(results).hasSize(1);
+ }
+
+ @Test
+ void searchByName_findsByAlias() {
+ personRepository.save(Person.builder()
+ .firstName("Hans").lastName("Müller").alias("Opa Hans").build());
+
+ List results = personRepository.searchByName("Opa Hans");
+
+ assertThat(results).hasSize(1);
+ }
+
+ // ─── findAllByOrderByLastNameAscFirstNameAsc ──────────────────────────────
+
+ @Test
+ void findAllByOrderByLastNameAscFirstNameAsc_returnsSortedByLastNameThenFirstName() {
+ personRepository.save(Person.builder().firstName("Bernd").lastName("Ziegler").build());
+ personRepository.save(Person.builder().firstName("Anna").lastName("Müller").build());
+ personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
+
+ List sorted = personRepository.findAllByOrderByLastNameAscFirstNameAsc();
+
+ assertThat(sorted).extracting(Person::getLastName)
+ .startsWith("Müller", "Müller");
+ assertThat(sorted.stream()
+ .filter(p -> p.getLastName().equals("Müller"))
+ .map(Person::getFirstName)
+ .toList())
+ .containsExactly("Anna", "Clara");
+ }
+
+ // ─── findByAliasIgnoreCase ────────────────────────────────────────────────
+
+ @Test
+ void findByAliasIgnoreCase_returnsMatchingPerson() {
+ personRepository.save(Person.builder()
+ .firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
+
+ Optional found = personRepository.findByAliasIgnoreCase("opa karl");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getFirstName()).isEqualTo("Karl");
+ }
+
+ @Test
+ void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
+ Optional found = personRepository.findByAliasIgnoreCase("nobody");
+
+ assertThat(found).isEmpty();
+ }
+
+ // ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
+
+ @Test
+ void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
+ personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
+
+ Optional found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
+ "maria", "raddatz");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getFirstName()).isEqualTo("Maria");
+ }
+}
diff --git a/backend/src/test/resources/application-test.yaml b/backend/src/test/resources/application-test.yaml
new file mode 100644
index 00000000..6a8cbad3
--- /dev/null
+++ b/backend/src/test/resources/application-test.yaml
@@ -0,0 +1,15 @@
+app:
+ s3:
+ endpoint: http://localhost:9000
+ access-key: dummy
+ secret-key: dummy
+ bucket: test-bucket
+ region: us-east-1
+
+spring:
+ datasource:
+ url: will-be-overridden-by-testcontainers
+ username: test
+ password: test
+ mail:
+ host: localhost