package example.grails.jobrunr
import spock.lang.Specification
class JobRequestSpec extends Specification {
void 'process request maps to its Spring handler and supports deserialization'() {
when:
ProcessDeliveryJobRequest request = new ProcessDeliveryJobRequest(42L)
ProcessDeliveryJobRequest emptyRequest = new ProcessDeliveryJobRequest()
then:
request.deliveryId == 42L
emptyRequest.deliveryId == null
request.jobRequestHandler == DeliveryJobRequestHandler
}
void 'retry request maps to its dedicated handler'() {
expect:
new RetryDeliveryJobRequest(7L).jobRequestHandler == RetryDeliveryJobRequestHandler
}
}
8 Test the Integration
The sample uses both focused unit tests and a live integration test. First, the request test protects the deserialization-friendly no-argument constructor and handler mapping:
The listener test checks that the production wiring keeps the decisive AFTER_COMMIT phase and submits the expected request:
package example.grails
import org.jobrunr.scheduling.JobRequestScheduler
import spock.lang.Specification
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
class DeliveryJobEnqueuerSpec extends Specification {
void 'listener runs only after the delivery transaction commits'() {
when:
TransactionalEventListener annotation = DeliveryJobEnqueuer
.getMethod('enqueue', DeliveryCreatedEvent)
.getAnnotation(TransactionalEventListener)
then:
annotation != null
annotation.phase() == TransactionPhase.AFTER_COMMIT
!annotation.fallbackExecution()
}
void 'listener maps a committed delivery event to a JobRequest'() {
given:
JobRequestScheduler scheduler = Mock()
DeliveryJobEnqueuer enqueuer = new DeliveryJobEnqueuer(jobRequestScheduler: scheduler)
when:
enqueuer.enqueue(new DeliveryCreatedEvent(17L))
then:
1 * scheduler.enqueue({ request -> request.deliveryId == 17L })
}
}
The storage configuration test verifies that the configured table prefix is used and that skip-create leaves an empty data source without JobRunr tables:
package example.grails.jobrunr
import javax.sql.DataSource
import org.jobrunr.JobRunrException
import org.jobrunr.spring.autoconfigure.JobRunrProperties
import org.springframework.jdbc.datasource.DriverManagerDataSource
import spock.lang.Specification
class JobRunrStorageConfigSpec extends Specification {
void 'storage configuration applies the JobRunr database properties'() {
given:
DataSource dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:jobrunr-storage-config-${System.nanoTime()};DB_CLOSE_DELAY=-1",
'sa',
''
)
JobRunrProperties properties = new JobRunrProperties()
properties.database.tablePrefix = 'GUIDE_'
properties.database.skipCreate = false
when:
new JobRunrStorageConfig().storageProvider(dataSource, properties)
then:
dataSource.connection.withCloseable { connection ->
connection.metaData.getTables(null, null, 'GUIDE%', null).next()
}
when:
DataSource skipCreateDataSource = new DriverManagerDataSource(
"jdbc:h2:mem:jobrunr-storage-config-skip-${System.nanoTime()};DB_CLOSE_DELAY=-1",
'sa',
''
)
properties.database.skipCreate = true
properties.database.tablePrefix = 'SKIP_'
new JobRunrStorageConfig().storageProvider(skipCreateDataSource, properties)
then:
thrown(JobRunrException)
!skipCreateDataSource.connection.withCloseable { connection ->
connection.metaData.getTables(null, null, 'SKIP%', null).next()
}
}
}
The HTTP integration test discovers the controller action and URL mapping, then sends a real request to the embedded server:
package example.grails
import grails.testing.mixin.integration.Integration
import grails.core.GrailsApplication
import grails.core.GrailsControllerClass
import grails.web.mapping.UrlMappingInfo
import grails.web.mapping.UrlMappingsHolder
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpMethod
import spock.lang.Specification
@Integration
class DeliveryHttpApiSpec extends Specification {
@Autowired
GrailsApplication grailsApplication
@Autowired
UrlMappingsHolder urlMappingsHolder
@Value('${local.server.port}')
int port
void 'POST deliveries routes to the documented create action'() {
given:
GrailsControllerClass deliveryController = grailsApplication.getArtefact('Controller', DeliveryController.name)
UrlMappingInfo[] deliveryMappings = urlMappingsHolder.matchAll('/deliveries', HttpMethod.POST)
when:
HttpURLConnection connection = new URL("http://localhost:${port}/deliveries").openConnection()
connection.requestMethod = 'POST'
connection.doOutput = true
connection.setRequestProperty('Accept', 'application/json')
connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded')
connection.outputStream.withCloseable { output ->
output.write('reference=delivery-http-route'.bytes)
}
then:
deliveryController.actions.contains('create')
deliveryMappings.any { it.controllerName == 'delivery' && it.actionName == 'create' }
connection.responseCode == 201
cleanup:
connection.disconnect()
}
void 'POST deliveries without a reference returns 422 and does not persist a delivery'() {
given:
int deliveryCount = Delivery.withNewSession { Delivery.count() }
when:
HttpURLConnection connection = new URL("http://localhost:${port}/deliveries").openConnection()
connection.requestMethod = 'POST'
connection.doOutput = true
connection.setRequestProperty('Accept', 'application/json')
connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded')
connection.outputStream.withCloseable { output ->
output.write(''.bytes)
}
then:
connection.responseCode == 422
Delivery.withNewSession { Delivery.count() } == deliveryCount
cleanup:
connection?.disconnect()
}
}
The sample also includes unit tests for the scheduler endpoints and service. They are not required to follow the earlier chapters, but they keep the JobExamplesController and JobExamplesService behavior in lockstep with the companion app:
package example.grails
import grails.testing.web.controllers.ControllerUnitTest
import groovy.json.JsonSlurper
import spock.lang.Specification
class JobExamplesControllerSpec extends Specification implements ControllerUnitTest<JobExamplesController> {
void 'immediate job endpoint returns an accepted response with a string job id'() {
given:
controller.jobExamplesService = Stub(JobExamplesService) {
enqueueImmediately(7L) >> 'b5ab0831-a3af-4554-9b7a-5571137e9aaa'
}
request.method = 'POST'
request.addHeader('Accept', 'application/json')
when:
controller.immediate(7L)
then:
response.status == 202
new JsonSlurper().parseText(response.text).jobId == 'b5ab0831-a3af-4554-9b7a-5571137e9aaa'
}
void 'recurring job endpoint requires a delivery id and returns its stable identifier'() {
given:
controller.jobExamplesService = Stub(JobExamplesService) {
registerRecurringDelivery(19L) >> 'delivery-19'
}
request.method = 'POST'
request.addHeader('Accept', 'application/json')
when:
controller.recurring(19L)
then:
response.status == 202
new JsonSlurper().parseText(response.text).recurringJobId == 'delivery-19'
}
}
package example.grails
import example.grails.jobrunr.ProcessDeliveryJobRequest
import org.jobrunr.scheduling.JobRequestScheduler
import spock.lang.Specification
class JobExamplesServiceSpec extends Specification {
void 'recurring delivery uses the delivery id for its stable JobRunr identifier and request'() {
given:
JobRequestScheduler scheduler = Mock()
JobExamplesService service = new JobExamplesService(jobRequestScheduler: scheduler)
when:
String recurringJobId = service.registerRecurringDelivery(19L)
then:
recurringJobId == 'delivery-19'
1 * scheduler.scheduleRecurrently(
'delivery-19',
'0 3 * * *',
{ ProcessDeliveryJobRequest request -> request.deliveryId == 19L }
) >> 'delivery-19'
}
}
Finally, the integration test starts JobRunr and GORM together. It proves that a live JobRequest runs to completion and that JOBRUNR_JOBS exists only in the dedicated JobRunr data source:
package example.grails
import grails.testing.mixin.integration.Integration
import javax.sql.DataSource
import org.jobrunr.storage.StorageProvider
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.transaction.event.TransactionalEventListenerFactory
import spock.lang.Specification
@Integration
class JobRunrIntegrationSpec extends Specification {
@Autowired
DeliveryService deliveryService
@Autowired
StorageProvider storageProvider
@Autowired
TransactionalEventListenerFactory transactionalEventListenerFactory
@Autowired
@Qualifier('dataSource')
DataSource applicationDataSource
@Autowired
@Qualifier('dataSource_jobrunr')
DataSource jobrunrDataSource
void 'JobRequest completes a GORM delivery while JobRunr owns separate tables'() {
when:
int existingJobs = storageProvider.jobStats.total
Delivery delivery = deliveryService.create("delivery-${System.nanoTime()}")
then:
eventually { storageProvider.jobStats.total == existingJobs + 1 }
eventually { loadDelivery(delivery.id)?.status == 'SUCCEEDED' }
loadDelivery(delivery.id).progress == 100
loadDelivery(delivery.id).completedAt != null
storageProvider != null
transactionalEventListenerFactory != null
tableExists(jobrunrDataSource, 'JOBRUNR_JOBS')
!tableExists(applicationDataSource, 'JOBRUNR_JOBS')
}
private static boolean eventually(Closure<Boolean> condition) {
for (int attempt = 0; attempt < 120; attempt++) {
if (condition.call()) {
return true
}
Thread.sleep(250)
}
false
}
private static boolean tableExists(DataSource dataSource, String tableName) {
dataSource.connection.withCloseable { connection ->
connection.metaData.getTables(null, null, tableName, null).next()
}
}
private static Delivery loadDelivery(Long id) {
Delivery.withNewTransaction {
Delivery.get(id)
}
}
}
Run the complete suite with:
cd complete
./gradlew test