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:

src/test/groovy/example/grails/jobrunr/JobRequestSpec.groovy
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
    }
}

The listener test checks that the production wiring keeps the decisive AFTER_COMMIT phase and submits the expected request:

src/test/groovy/example/grails/DeliveryJobEnqueuerSpec.groovy
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:

src/test/groovy/example/grails/jobrunr/JobRunrStorageConfigSpec.groovy
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:

src/test/groovy/example/grails/DeliveryHttpApiSpec.groovy
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:

src/test/groovy/example/grails/JobExamplesControllerSpec.groovy
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'
    }
}
src/test/groovy/example/grails/JobExamplesServiceSpec.groovy
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:

src/test/groovy/example/grails/JobRunrIntegrationSpec.groovy
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
  Get the Code