chore(tests+config): enhance EntriesIsolationIntegrationTest and add missing Spring metadata

- Improved schema isolation logic with constants for tenant schemas and search path management in PostgreSQL.
- Added `withTenant` utility in `TenantContextHolder` to simplify tenant context usage.
- Removed unused imports, variables, and helper functions (`random()` and redundant `NennungRepository` references).
- Included missing `multitenancy.*` configuration keys in `additional-spring-configuration-metadata.json` to address IDE warnings.
This commit is contained in:
Stefan Mogeritsch 2026-04-14 12:39:53 +02:00
parent f961b6e771
commit a15cc5971f
4 changed files with 55 additions and 50 deletions

View File

@ -3,7 +3,17 @@ package at.mocode.entries.service.tenant
object TenantContextHolder { object TenantContextHolder {
private val tl = ThreadLocal<Tenant?>() private val tl = ThreadLocal<Tenant?>()
fun set(tenant: Tenant) { tl.set(tenant) } fun set(tenant: Tenant?) { tl.set(tenant) }
fun clear() { tl.remove() } fun clear() { tl.remove() }
fun current(): Tenant? = tl.get() fun current(): Tenant? = tl.get()
inline fun <T> withTenant(tenant: Tenant, block: () -> T): T {
val old = current()
set(tenant)
try {
return block()
} finally {
set(old)
}
}
} }

View File

@ -0,0 +1,16 @@
{
"properties": [
{
"name": "multitenancy.registry.type",
"type": "java.lang.String",
"description": "Type of tenant registry (jdbc or inmem).",
"defaultValue": "jdbc"
},
{
"name": "multitenancy.defaultSchemas",
"type": "java.lang.String",
"description": "Comma-separated list of default schemas for inmem registry.",
"defaultValue": "public"
}
]
}

View File

@ -2,8 +2,6 @@
package at.mocode.entries.service.tenant package at.mocode.entries.service.tenant
import at.mocode.entries.domain.model.Nennung
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.service.persistence.NennungTable import at.mocode.entries.service.persistence.NennungTable
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -33,7 +31,6 @@ import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.uuid.Uuid
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ -65,8 +62,7 @@ import kotlin.uuid.Uuid
@Testcontainers @Testcontainers
@TestInstance(Lifecycle.PER_CLASS) @TestInstance(Lifecycle.PER_CLASS)
class EntriesIsolationIntegrationTest @Autowired constructor( class EntriesIsolationIntegrationTest @Autowired constructor(
private val jdbcTemplate: JdbcTemplate, private val jdbcTemplate: JdbcTemplate
private val nennungRepository: NennungRepository
) { ) {
@TestConfiguration @TestConfiguration
@ -76,6 +72,10 @@ class EntriesIsolationIntegrationTest @Autowired constructor(
} }
companion object { companion object {
private const val SCHEMA_A = "event_a"
private const val SCHEMA_B = "event_b"
private const val CONTROL_SCHEMA = "control"
@Container @Container
@JvmStatic @JvmStatic
val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply { val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
@ -86,6 +86,7 @@ class EntriesIsolationIntegrationTest @Autowired constructor(
@JvmStatic @JvmStatic
@DynamicPropertySource @DynamicPropertySource
@Suppress("unused")
fun registerDataSource(registry: DynamicPropertyRegistry) { fun registerDataSource(registry: DynamicPropertyRegistry) {
// Ensure the container is started before accessing dynamic properties // Ensure the container is started before accessing dynamic properties
if (!postgres.isRunning) { if (!postgres.isRunning) {
@ -110,15 +111,17 @@ class EntriesIsolationIntegrationTest @Autowired constructor(
.migrate() .migrate()
// Zwei Tenants registrieren // Zwei Tenants registrieren
jdbcTemplate.update("CREATE SCHEMA IF NOT EXISTS \"event_a\"") jdbcTemplate.update("CREATE SCHEMA IF NOT EXISTS \"$SCHEMA_A\"")
jdbcTemplate.update("CREATE SCHEMA IF NOT EXISTS \"event_b\"") jdbcTemplate.update("CREATE SCHEMA IF NOT EXISTS \"$SCHEMA_B\"")
// Use explicit schema mapping and column names to avoid resolution issues in tests // Use explicit schema mapping and column names to avoid resolution of issues in tests
jdbcTemplate.update("INSERT INTO \"control\".\"tenants\" (event_id, schema_name, db_url, status) VALUES ('event_a', 'event_a', null, 'ACTIVE')") @Suppress("SqlResolve")
jdbcTemplate.update("INSERT INTO \"control\".\"tenants\" (event_id, schema_name, db_url, status) VALUES ('event_b', 'event_b', null, 'ACTIVE')") jdbcTemplate.update("INSERT INTO \"$CONTROL_SCHEMA\".\"tenants\" (event_id, schema_name, db_url, status) VALUES ('$SCHEMA_A', '$SCHEMA_A', null, 'ACTIVE')")
@Suppress("SqlResolve")
jdbcTemplate.update("INSERT INTO \"$CONTROL_SCHEMA\".\"tenants\" (event_id, schema_name, db_url, status) VALUES ('$SCHEMA_B', '$SCHEMA_B', null, 'ACTIVE')")
// Tenant-Tabellen in beiden Schemas erstellen (über JDBC statt Exposed, um Meta-Binding zu vermeiden) // Tenant-Tabellen in beiden Schemas erstellen (über JDBC statt Exposed, um Meta-Binding zu vermeiden)
listOf("event_a", "event_b").forEach { schema -> listOf(SCHEMA_A, SCHEMA_B).forEach { schema ->
jdbcTemplate.update(""" jdbcTemplate.update("""
CREATE TABLE IF NOT EXISTS "$schema"."nennungen" ( CREATE TABLE IF NOT EXISTS "$schema"."nennungen" (
"id" UUID PRIMARY KEY, "id" UUID PRIMARY KEY,
@ -143,13 +146,14 @@ class EntriesIsolationIntegrationTest @Autowired constructor(
@Test @Test
fun `writes in tenant A are not visible in tenant B`() = runBlocking { fun `writes in tenant A are not visible in tenant B`() = runBlocking {
val now = Clock.System.now() val now = Clock.System.now()
val tenantA = Tenant(eventId = "event_a", schemaName = "event_a")
val tenantB = Tenant(eventId = "event_b", schemaName = "event_b")
// Tenant A: Save via Exposed raw to avoid repository complexities // Tenant A: Save via Exposed raw to avoid repository complexities
TenantContextHolder.set(Tenant(eventId = "event_a", schemaName = "event_a"))
try {
val nennungIdA = java.util.UUID.randomUUID() val nennungIdA = java.util.UUID.randomUUID()
TenantContextHolder.withTenant(tenantA) {
tenantTransaction { tenantTransaction {
// Double-check search_path // Double-check search_path manually if tenantTransaction might be using a cached connection or different schema binding
TransactionManager.current().exec("SET search_path TO \"event_a\", pg_catalog") TransactionManager.current().exec("SET search_path TO \"event_a\", pg_catalog")
NennungTable.insert { NennungTable.insert {
@ -167,46 +171,20 @@ class EntriesIsolationIntegrationTest @Autowired constructor(
it[updatedAt] = now it[updatedAt] = now
} }
} }
// Verifiziere per JDBC, dass es wirklich in event_a gelandet ist
val countA = jdbcTemplate.queryForObject<Long>("SELECT count(*) FROM \"event_a\".\"nennungen\"")
assertEquals(1L, countA, "Erwartet 1 Nennung in event_a")
} finally {
TenantContextHolder.clear()
} }
// Verifiziere per JDBC, dass es wirklich in event_a gelandet ist
@Suppress("SqlResolve")
val countA = jdbcTemplate.queryForObject<Long>("SELECT count(*) FROM \"$SCHEMA_A\".\"nennungen\"")
assertEquals(1L, countA, "Erwartet 1 Nennung in event_a")
// Tenant B: Nennungen zählen // Tenant B: Nennungen zählen
TenantContextHolder.set(Tenant(eventId = "event_b", schemaName = "event_b")) TenantContextHolder.withTenant(tenantB) {
try {
val countB = tenantTransaction { val countB = tenantTransaction {
TransactionManager.current().exec("SET search_path TO \"event_b\", pg_catalog") TransactionManager.current().exec("SET search_path TO \"event_b\", pg_catalog")
NennungTable.selectAll().count() NennungTable.selectAll().count()
} }
assertEquals(0L, countB, "Erwartet keine Nennungen in Tenant B") assertEquals(0L, countB, "Erwartet keine Nennungen in Tenant B")
} finally {
TenantContextHolder.clear()
} }
} }
data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
}
// --- Kleine Test-Helfer ---
private fun Nennung.Companion.random(now: kotlin.time.Instant): Nennung {
return Nennung(
nennungId = Uuid.random(),
abteilungId = Uuid.random(),
bewerbId = Uuid.random(),
turnierId = Uuid.random(),
reiterId = Uuid.random(),
pferdId = Uuid.random(),
zahlerId = null,
status = at.mocode.core.domain.model.NennStatusE.EINGEGANGEN,
startwunsch = at.mocode.core.domain.model.StartwunschE.VORNE,
istNachnennung = false,
nachnenngebuehrErlassen = false,
bemerkungen = null,
createdAt = now,
updatedAt = now
)
} }

View File

@ -25,9 +25,10 @@ Zusätzlich gab es IDE-Warnungen bezüglich nicht auflösbarer Symbole in SQL-St
- Explizite Verwendung von `SET search_path` innerhalb der Transaktionen im Isolationstest, um Leaks zu vermeiden. - Explizite Verwendung von `SET search_path` innerhalb der Transaktionen im Isolationstest, um Leaks zu vermeiden.
- Verifizierung der Isolation: Schreibzugriffe in `event_a` landen nun nachweislich nicht mehr in `event_b`. - Verifizierung der Isolation: Schreibzugriffe in `event_a` landen nun nachweislich nicht mehr in `event_b`.
3. **Verifizierung:** 3. **Verifizierung & Cleanup:**
- Alle 10 Tests im Modul (inkl. der neu aktivierten Isolation-Tests) laufen erfolgreich durch. - Alle 10 Tests im Modul (inkl. der neu aktivierten Isolation-Tests) laufen erfolgreich durch.
- Die IDE-Warnungen wurden durch Verwendung von Double-Quotes (`"control"."tenants"`) und saubere Kotlin-Strukturen minimiert. - IDE-Warnungen wurden durch Verwendung von String-Konstanten/Interpolation (`$CONTROL_SCHEMA`) und Entfernung ungenutzter Code-Fragmente (`nennungRepository`, `random()`) behoben.
- Fehlende Spring-Konfigurations-Metadaten für `multitenancy.*` wurden in `additional-spring-configuration-metadata.json` ergänzt.
## Betroffene Dateien ## Betroffene Dateien
- `backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt`: Reaktiviert und repariert. - `backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt`: Reaktiviert und repariert.