diff --git a/.env.example b/.env.example index b698b29f..97d677e9 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,7 @@ GLITCHTIP_SECRET_KEY=changeme-generate-a-real-secret # Error reporting DSNs — leave empty to disable the SDK (safe default). # SENTRY_DSN: backend (Spring Boot) — used by the GlitchTip/Sentry Java SDK SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE= # VITE_SENTRY_DSN: frontend (SvelteKit) — injected at build time via Vite VITE_SENTRY_DSN= # Sentry/GlitchTip auth token for source map upload at build time (optional) diff --git a/backend/pom.xml b/backend/pom.xml index 7ac0a6a5..74d513cd 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -224,6 +224,15 @@ + + + + io.sentry + sentry-spring-boot-4 + 8.41.0 + diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index e99acfdd..aa33094d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.exception; import java.util.stream.Collectors; +import io.sentry.Sentry; import jakarta.validation.ConstraintViolationException; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -63,6 +64,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { + Sentry.captureException(ex); log.error("Unhandled exception", ex); return ResponseEntity.internalServerError() .body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred")); diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 6198f3d9..ead3d9e8 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -118,3 +118,12 @@ ocr: sender-model: activation-threshold: 100 retrain-delta: 50 + +sentry: + dsn: ${SENTRY_DSN:} + environment: ${SPRING_PROFILES_ACTIVE:dev} + traces-sample-rate: ${SENTRY_TRACES_SAMPLE_RATE:1.0} + send-default-pii: false + enable-tracing: true + ignored-exceptions-for-type: + - org.raddatz.familienarchiv.exception.DomainException diff --git a/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java b/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java index 899c526c..f7eaa7b9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java @@ -1,14 +1,18 @@ package org.raddatz.familienarchiv; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.ApplicationContext; 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; +import static org.assertj.core.api.Assertions.assertThat; + @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) @@ -17,9 +21,18 @@ class ApplicationContextTest { @MockitoBean S3Client s3Client; + @Autowired + ApplicationContext ctx; + @Test void contextLoads() { // verifies that the Spring context starts successfully with all beans wired, // Flyway migrations applied, and no configuration errors } + + @Test + void sentry_is_disabled_when_no_dsn_is_configured() { + // application-test.yaml has no sentry.dsn — SDK must stay inactive so tests are clean + assertThat(io.sentry.Sentry.isEnabled()).isFalse(); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java index b8401d18..16db59be 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java @@ -1,11 +1,11 @@ package org.raddatz.familienarchiv.audit; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.support.TransactionTemplate; @@ -18,7 +18,6 @@ import static org.awaitility.Awaitility.await; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class AuditServiceIntegrationTest { @MockitoBean S3Client s3Client; @@ -26,6 +25,11 @@ class AuditServiceIntegrationTest { @Autowired AuditLogRepository auditLogRepository; @Autowired TransactionTemplate transactionTemplate; + @BeforeEach + void resetAuditLog() { + auditLogRepository.deleteAll(); + } + @Test void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() { transactionTemplate.execute(status -> { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java index bdf9f810..0ba61da3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java @@ -12,9 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.s3.S3Client; import java.time.LocalDate; @@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@Transactional class DocumentSearchPagedIntegrationTest { private static final int FIXTURE_SIZE = 120; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..a12933b8 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,33 @@ +package org.raddatz.familienarchiv.exception; + +import io.sentry.Sentry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockStatic; + +@ExtendWith(MockitoExtension.class) +class GlobalExceptionHandlerTest { + + @InjectMocks + private GlobalExceptionHandler handler; + + @Test + void handleGeneric_captures_exception_in_sentry_and_returns_500() { + RuntimeException ex = new RuntimeException("unexpected failure"); + + try (MockedStatic sentryMock = mockStatic(Sentry.class)) { + ResponseEntity response = handler.handleGeneric(ex); + + sentryMock.verify(() -> Sentry.captureException(ex)); + assertThat(response.getStatusCode().value()).isEqualTo(500); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR); + } + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index 55eaaa4c..31c73af1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -19,9 +19,9 @@ import org.springframework.context.annotation.Import; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.s3.S3Client; import java.util.List; @@ -32,7 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@Transactional class GeschichteServiceIntegrationTest { @MockitoBean diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java index 02cc7aa4..e8d5ed97 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java @@ -8,9 +8,9 @@ import org.raddatz.familienarchiv.person.PersonRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.s3.S3Client; import static org.assertj.core.api.Assertions.assertThat; @@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@Transactional class PersonServiceIntegrationTest { @MockitoBean S3Client s3Client; diff --git a/docker-compose.yml b/docker-compose.yml index 3e42c981..2a3b7407 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -147,6 +147,8 @@ services: SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false} APP_OCR_BASE_URL: http://ocr-service:8000 APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}" + SENTRY_DSN: ${SENTRY_DSN:-} + SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0} # Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317) # Tempo is defined in docker-compose.observability.yml (future issue). # OTLP failures are non-fatal — backend starts cleanly without the observability stack.