diff --git a/src/test/kotlin/dev/whrite/opsgrid/shift/ConcurrentShiftUpdateLeaveConflictIntegrationTest.kt b/src/test/kotlin/dev/whrite/opsgrid/shift/ConcurrentShiftUpdateLeaveConflictIntegrationTest.kt new file mode 100644 index 0000000..c712602 --- /dev/null +++ b/src/test/kotlin/dev/whrite/opsgrid/shift/ConcurrentShiftUpdateLeaveConflictIntegrationTest.kt @@ -0,0 +1,156 @@ +package dev.whrite.opsgrid.shift + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.sql.Connection +import java.sql.DriverManager +import java.sql.SQLException +import java.time.OffsetDateTime +import java.util.UUID +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class ConcurrentShiftUpdateLeaveConflictIntegrationTest { + @Test + fun concurrentShiftUpdateAndLeaveApprovalCannotCreateCrossTableOverlap() { + val url = System.getenv("TEST_DATABASE_URL") + assumeTrue(!url.isNullOrBlank(), "TEST_DATABASE_URL must point to a disposable PostgreSQL test database") + val user = System.getenv("TEST_DATABASE_USER") ?: "opsgrid" + val password = System.getenv("TEST_DATABASE_PASSWORD") ?: "opsgrid" + val schema = "opsgrid_update_leave_test_" + UUID.randomUUID().toString().replace("-", "") + val employeeId = UUID.randomUUID() + val shiftId = UUID.randomUUID() + + DriverManager.getConnection(url, user, password).use { admin -> + admin.createStatement().use { it.execute("CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public") } + admin.createStatement().use { it.execute("CREATE SCHEMA \"$schema\"") } + } + + try { + DriverManager.getConnection(url, user, password).use { connection -> + setSearchPath(connection, schema) + listOf( + "V1__initial_schema.sql", + "V2__employee_leave.sql", + "V3__schedule_consistency.sql" + ).forEach { executeMigration(connection, it) } + val migration = Files.readString( + Path.of("src/main/resources/db/migration/V5__prevent_shift_leave_overlap.sql") + ) + connection.createStatement().use { it.execute(migration) } + + connection.prepareStatement( + """INSERT INTO employees (id, employee_number, first_name, last_name, department, active) + VALUES (?, ?, ?, ?, ?, TRUE)""" + ).use { statement -> + statement.setObject(1, employeeId) + statement.setString(2, "EMP-${employeeId.toString().take(8)}") + statement.setString(3, "Update") + statement.setString(4, "Integration") + statement.setString(5, "Operations") + statement.executeUpdate() + } + connection.prepareStatement( + """INSERT INTO shifts (id, employee_id, starts_at, ends_at, status) + VALUES (?, ?, ?, ?, 'SCHEDULED')""" + ).use { statement -> + statement.setObject(1, shiftId) + statement.setObject(2, employeeId) + statement.setObject(3, OffsetDateTime.parse("2026-10-21T08:00:00Z")) + statement.setObject(4, OffsetDateTime.parse("2026-10-21T12:00:00Z")) + statement.executeUpdate() + } + } + + val barrier = CyclicBarrier(2) + val executor = Executors.newFixedThreadPool(2) + try { + val operations = listOf("shift-update", "leave-approval") + val results = operations.map { operation -> + executor.submit { + DriverManager.getConnection(url, user, password).use { connection -> + setSearchPath(connection, schema) + connection.autoCommit = false + try { + barrier.await(10, TimeUnit.SECONDS) + if (operation == "shift-update") { + connection.prepareStatement( + "UPDATE shifts SET ends_at = ? WHERE id = ?" + ).use { statement -> + statement.setObject(1, OffsetDateTime.parse("2026-10-21T16:00:00Z")) + statement.setObject(2, shiftId) + statement.executeUpdate() + } + } else { + connection.prepareStatement( + """INSERT INTO employee_leave (id, employee_id, starts_at, ends_at, reason, status) + VALUES (?, ?, ?, ?, 'annual leave', 'APPROVED')""" + ).use { statement -> + statement.setObject(1, UUID.randomUUID()) + statement.setObject(2, employeeId) + statement.setObject(3, OffsetDateTime.parse("2026-10-21T12:00:00Z")) + statement.setObject(4, OffsetDateTime.parse("2026-10-21T16:00:00Z")) + statement.executeUpdate() + } + } + connection.commit() + "$operation:success" + } catch (error: SQLException) { + connection.rollback() + if (error.sqlState == "23P01") "$operation:conflict" else throw error + } catch (error: Exception) { + connection.rollback() + throw error + } + } + } + }.map { it.get(20, TimeUnit.SECONDS) } + + assertEquals(1, results.count { it.endsWith(":success") }, "exactly one scheduling mutation should commit") + assertEquals(1, results.count { it.endsWith(":conflict") }, "the conflicting mutation must be rejected") + + DriverManager.getConnection(url, user, password).use { connection -> + setSearchPath(connection, schema) + connection.prepareStatement( + """SELECT COUNT(*) + FROM shifts s + JOIN employee_leave l ON l.employee_id = s.employee_id + WHERE s.employee_id = ? + AND s.status = 'SCHEDULED' + AND l.status = 'APPROVED' + AND tstzrange(s.starts_at, s.ends_at, '[)') + && tstzrange(l.starts_at, l.ends_at, '[)')""" + ).use { statement -> + statement.setObject(1, employeeId) + statement.executeQuery().use { result -> + result.next() + assertEquals(0, result.getInt(1), "no scheduled shift may overlap approved leave") + } + } + } + } finally { + executor.shutdownNow() + } + } finally { + DriverManager.getConnection(url, user, password).use { admin -> + admin.createStatement().use { it.execute("DROP SCHEMA IF EXISTS \"$schema\" CASCADE") } + } + } + } + + private fun setSearchPath(connection: Connection, schema: String) { + connection.createStatement().use { it.execute("SET search_path TO \"$schema\", public") } + } + + private fun executeMigration(connection: Connection, filename: String) { + val sql = Files.readString(Path.of("src/main/resources/db/migration", filename)) + sql.split(';') + .map(String::trim) + .filter(String::isNotEmpty) + .forEach { statement -> connection.createStatement().use { it.execute(statement) } } + } +}