// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.

import Foundation
import GRDB

import Quick
import Nimble

@testable import SessionUtilitiesKit

class JobRunnerSpec: QuickSpec {
    override class func spec() {
        // MARK: Configuration
        
        @TestState var job1: Job! = Job(
            id: 100,
            failureCount: 0,
            variant: .messageSend,
            behaviour: .runOnce,
            shouldBlock: false,
            shouldSkipLaunchBecomeActive: false,
            nextRunTimestamp: 0,
            threadId: nil,
            interactionId: nil,
            details: nil
        )
        @TestState var job2: Job! = Job(
            id: 101,
            failureCount: 0,
            variant: .attachmentUpload,
            behaviour: .runOnce,
            shouldBlock: false,
            shouldSkipLaunchBecomeActive: false,
            nextRunTimestamp: 0,
            threadId: nil,
            interactionId: nil,
            details: nil
        )
        @TestState var mockStorage: Storage! = SynchronousStorage(
            customWriter: try! DatabaseQueue(),
            migrationTargets: [
                SNUtilitiesKit.self
            ],
            initialData: { db in
                // Migrations add jobs which we don't want so delete them
                try Job.deleteAll(db)
            }
        )
        @TestState var dependencies: Dependencies! = Dependencies(
            storage: mockStorage,
            dateNow: Date(timeIntervalSince1970: 0),
            forceSynchronous: true
        )
        @TestState var jobRunner: JobRunnerType! = {
            let result = JobRunner(isTestingJobRunner: true, using: dependencies)
            result.setExecutor(TestJob.self, for: .messageSend)
            result.setExecutor(TestJob.self, for: .attachmentUpload)
            result.setExecutor(TestJob.self, for: .messageReceive)
            
            // Need to assign this to ensure it's used by nested dependencies
            dependencies.jobRunner = result
            
            return result
        }()
        
        // MARK: - a JobRunner
        describe("a JobRunner") {
            afterEach {
                /// We **must** set `fixedTime` to ensure we break any loops within the `TestJob` executor
                dependencies.fixedTime = Int.max
                jobRunner.stopAndClearPendingJobs()
            }
            
            // MARK: -- when configuring
            context("when configuring") {
                // MARK: ---- adds an executor correctly
                it("adds an executor correctly") {
                    job1 = Job(
                        id: 101,
                        failureCount: 0,
                        variant: .getSnodePool,
                        behaviour: .runOnce,
                        shouldBlock: false,
                        shouldSkipLaunchBecomeActive: false,
                        nextRunTimestamp: 0,
                        threadId: nil,
                        interactionId: nil,
                        details: try? JSONEncoder()
                            .with(outputFormatting: .sortedKeys)
                            .encode(TestDetails(completeTime: 1))
                    )
                    jobRunner.appDidFinishLaunching(using: dependencies)
                    jobRunner.appDidBecomeActive(using: dependencies)
                    
                    // Save the job to the database
                    mockStorage.write { db in _ = try job1.inserted(db) }
                    expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(1))
                    
                    // Try to start the job
                    mockStorage.write { db in
                        jobRunner.upsert(
                            db,
                            job: job1,
                            canStartJob: true,
                            using: dependencies
                        )
                    }
                    
                    // Ensure the job isn't running, and that it has been deleted (can't retry if there
                    // is no executer so no failure counts)
                    expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                    expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
                    
                    // Add the executor and start the job again
                    jobRunner.setExecutor(TestJob.self, for: .getSnodePool)
                    
                    mockStorage.write { db in
                        jobRunner.add(
                            db,
                            job: job1,
                            canStartJob: true,
                            using: dependencies
                        )
                    }
                    
                    // Job is now running
                    expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
                }
            }
            
            // MARK: ---- when managing state
            context("when managing state") {
                // MARK: ------ by checking if a job is currently running
                context("by checking if a job is currently running") {
                    // MARK: -------- returns false when not given a job
                    it("returns false when not given a job") {
                        expect(jobRunner.isCurrentlyRunning(nil)).to(beFalse())
                    }
                    
                    // MARK: -------- returns false when given a job that has not been persisted
                    it("returns false when given a job that has not been persisted") {
                        job1 = Job(variant: .messageSend)
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                    }
                    
                    // MARK: -------- returns false when given a job that is not running
                    it("returns false when given a job that is not running") {
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                    }
                    
                    // MARK: -------- returns true when given a non blocking job that is running
                    it("returns true when given a non blocking job that is running") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
                    }
                    
                    // MARK: -------- returns true when given a blocking job that is running
                    it("returns true when given a blocking job that is running") {
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnceNextLaunch,
                            shouldBlock: true,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
                    }
                }
                
                // MARK: ------ by getting the details for jobs
                context("by getting the details for jobs") {
                    // MARK: -------- returns an empty dictionary when there are no jobs
                    it("returns an empty dictionary when there are no jobs") {
                        expect(jobRunner.allJobInfo()).to(equal([:]))
                    }
                    
                    // MARK: -------- returns an empty dictionary when there are no jobs matching the filters
                    it("returns an empty dictionary when there are no jobs matching the filters") {
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)).to(equal([:]))
                    }
                    
                    // MARK: -------- can filter to specific jobs
                    it("can filter to specific jobs") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: nil
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Validate the filtering works
                        expect(jobRunner.allJobInfo()).toNot(beEmpty())
                        expect(jobRunner.jobInfoFor(jobs: [job1]))
                            .to(equal([
                                100: JobRunner.JobInfo(
                                    variant: .messageReceive,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: job1.details
                                )
                            ]))
                        expect(jobRunner.jobInfoFor(jobs: [job2]))
                            .to(equal([
                                101: JobRunner.JobInfo(
                                    variant: .messageReceive,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: nil
                                )
                            ]))
                    }
                    
                    // MARK: -------- can filter to running jobs
                    it("can filter to running jobs") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: false,
                                using: dependencies
                            )
                        }
                        
                        // Wait for there to be data and the validate the filtering works
                        expect(jobRunner.jobInfoFor(state: .running))
                            .to(equal([
                                100: JobRunner.JobInfo(
                                    variant: .messageReceive,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(completeTime: 1))
                                )
                            ]))
                        expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
                    }
                    
                    // MARK: -------- can filter to pending jobs
                    it("can filter to pending jobs") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Wait for there to be data and the validate the filtering works
                        expect(jobRunner.jobInfoFor(state: .pending))
                            .to(equal([
                                101: JobRunner.JobInfo(
                                    variant: .messageReceive,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(completeTime: 1))
                                )
                            ]))
                        expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
                    }
                    
                    // MARK: -------- can filter to specific variants
                    it("can filter to specific variants") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        job2 = job2.with(details: TestDetails(completeTime: 2))
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Wait for there to be data and the validate the filtering works
                        expect(jobRunner.jobInfoFor(variant: .attachmentUpload))
                            .to(equal([
                                101: JobRunner.JobInfo(
                                    variant: .attachmentUpload,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(completeTime: 2))
                                )
                            ]))
                        expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
                    }
                    
                    // MARK: -------- includes non blocking jobs
                    it("includes non blocking jobs") {
                        job2 = job2.with(details: TestDetails(completeTime: 1))
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload))
                            .to(equal([
                                101: JobRunner.JobInfo(
                                    variant: .attachmentUpload,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(completeTime: 1))
                                )
                            ]))
                    }
                    
                    // MARK: -------- includes blocking jobs
                    it("includes blocking jobs") {
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .attachmentUpload,
                            behaviour: .runOnceNextLaunch,
                            shouldBlock: true,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try! JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload))
                            .to(equal([
                                101: JobRunner.JobInfo(
                                    variant: .attachmentUpload,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(completeTime: 1))
                                )
                            ]))
                    }
                }
                
                // MARK: ------ by checking for an existing job
                context("by checking for an existing job") {
                    // MARK: -------- returns false for a queue that doesn't exist
                    it("returns false for a queue that doesn't exist") {
                        jobRunner = JobRunner(
                            isTestingJobRunner: true,
                            variantsToExclude: [.attachmentUpload],
                            using: dependencies
                        )
                        
                        expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails()))
                            .to(beFalse())
                    }
                    
                    // MARK: -------- returns false when the provided details fail to decode
                    it("returns false when the provided details fail to decode") {
                        expect(jobRunner.hasJob(of: .attachmentUpload, with: InvalidDetails()))
                            .to(beFalse())
                    }
                    
                    // MARK: -------- returns false when there is not a pending or running job
                    it("returns false when there is not a pending or running job") {
                        expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails()))
                            .to(beFalse())
                    }
                    
                    // MARK: -------- returns true when there is a pending job
                    it("returns true when there is a pending job") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageReceive,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 2))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(Array(jobRunner.jobInfoFor(state: .pending, variant: .messageReceive).keys))
                            .to(equal([101]))
                        expect(jobRunner.hasJob(of: .messageReceive, with: TestDetails(completeTime: 2)))
                            .to(beTrue())
                    }
                    
                    // MARK: -------- returns true when there is a running job
                    it("returns true when there is a running job") {
                        job2 = job2.with(details: TestDetails(completeTime: 1))
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1)))
                            .to(beTrue())
                    }
                    
                    // MARK: -------- returns true when there is a blocking job
                    it("returns true when there is a blocking job") {
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .attachmentUpload,
                            behaviour: .runOnceNextLaunch,
                            shouldBlock: true,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try! JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Need to add the job before starting it since it's a 'runOnceNextLaunch'
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1)))
                            .to(beTrue())
                    }
                    
                    // MARK: -------- returns true when there is a non blocking job
                    it("returns true when there is a non blocking job") {
                        job2 = job2.with(details: TestDetails(completeTime: 1))
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1)))
                            .to(beTrue())
                    }
                }
                
                // MARK: ------ by being notified of app launch
                context("by being notified of app launch") {
                    // MARK: -------- does not start a job before getting the app launch call
                    it("does not start a job before getting the app launch call") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                    }
                    
                    // MARK: -------- starts the job queues if there are no app launch jobs
                    it("does nothing if there are no app launch jobs") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        expect(jobRunner.allJobInfo()).to(beEmpty())
                    }
                }
                
                // MARK: ------ by being notified of app becoming active
                context("by being notified of app becoming active") {
                    // MARK: -------- does not start a job before getting the app active call
                    it("does not start a job before getting the app active call") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                    }
                    
                    // MARK: -------- does not start the job queues if there are no app active jobs and blocking jobs are running
                    it("does not start the job queues if there are no app active jobs and blocking jobs are running") {
                        job1 = job1.with(details: TestDetails(completeTime: 2))
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnceNextLaunch,
                            shouldBlock: true,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Not currently running
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                        
                        // Start the blocking job
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        // Make sure the other queues don't start
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                    }
                    
                    // MARK: -------- does not start the job queues if there are app active jobs and blocking jobs are running
                    it("does not start the job queues if there are app active jobs and blocking jobs are running") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .recurringOnActive,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 2))
                        )
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnceNextLaunch,
                            shouldBlock: true,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Not currently running
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                        
                        // Start the blocking queue
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        // Make sure the other queues don't start
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                    }
                    
                    // MARK: -------- starts the job queues if there are no app active jobs
                    it("starts the job queues if there are no app active jobs") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Make sure it isn't already started
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                        
                        // Make sure it starts after 'appDidBecomeActive' is called
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
                    }
                    
                    // MARK: -------- starts the job queues if there are app active jobs
                    it("starts the job queues if there are app active jobs") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .recurringOnActive,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Not currently running
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
                        
                        // Make sure the queues are started
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
                    }
                    
                    // MARK: -------- starts the job queues after completing blocking app launch jobs
                    it("starts the job queues after completing blocking app launch jobs") {
                        job1 = job1.with(details: TestDetails(completeTime: 2))
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnceNextLaunch,
                            shouldBlock: true,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try! JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Not currently running
                        expect(jobRunner.isCurrentlyRunning(job1))
                            .to(beFalse())
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
                        
                        // Make sure it starts
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        // Blocking job running but blocked job not
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                        
                        // Complete 'job2'
                        dependencies.stepForwardInTime()
                        
                        // Blocked job eventually starts
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
                    }
                    
                    // MARK: -------- starts the job queues alongside non blocking app launch jobs
                    it("starts the job queues alongside non blocking app launch jobs") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        job2 = Job(
                            id: 101,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnceNextLaunch,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try! JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                            jobRunner.add(
                                db,
                                job: job2,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        // Not currently running
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
                        
                        // Make sure it starts
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
                        expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
                    }
                }
                
                // MARK: ------ by checking if a job can be added to the queue
                context("by checking if a job can be added to the queue") {
                    // MARK: -------- does not add a general job to the queue before launch
                    it("does not add a general job to the queue before launch") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(jobRunner.allJobInfo()).to(beEmpty())
                    }
                    
                    // MARK: -------- adds a launch job to the queue in a pending state before launch
                    it("adds a launch job to the queue in a pending state before launch") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .recurringOnLaunch,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(Array(jobRunner.jobInfoFor(state: [.pending]).keys)).to(equal([100]))
                    }
                    
                    // MARK: -------- does not add a general job to the queue after launch but before becoming active
                    it("does not add a general job to the queue after launch but before becoming active") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(jobRunner.allJobInfo()).to(beEmpty())
                    }
                    
                    // MARK: -------- adds a launch job to the queue in a pending state after launch but before becoming active
                    it("adds a launch job to the queue in a pending state after launch but before becoming active") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .recurringOnLaunch,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(Array(jobRunner.jobInfoFor(state: .pending).keys)).to(equal([100]))
                    }
                    
                    // MARK: -------- adds a general job to the queue after becoming active
                    it("adds a general job to the queue after becoming active") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .runOnce,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(Array(jobRunner.allJobInfo().keys)).to(equal([100]))
                    }
                    
                    // MARK: -------- adds a launch job to the queue and starts it after becoming active
                    it("adds a launch job to the queue and starts it after becoming active") {
                        job1 = Job(
                            id: 100,
                            failureCount: 0,
                            variant: .messageSend,
                            behaviour: .recurringOnLaunch,
                            shouldBlock: false,
                            shouldSkipLaunchBecomeActive: false,
                            nextRunTimestamp: 0,
                            threadId: nil,
                            interactionId: nil,
                            details: try? JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(completeTime: 1))
                        )
                        jobRunner.appDidFinishLaunching(using: dependencies)
                        jobRunner.appDidBecomeActive(using: dependencies)
                        
                        mockStorage.write { db in
                            jobRunner.add(
                                db,
                                job: job1,
                                canStartJob: true,
                                using: dependencies
                            )
                        }
                        
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                    }
                }
            }
            
            // MARK: ---- when running jobs
            context("when running jobs") {
                beforeEach {
                    jobRunner.appDidFinishLaunching(using: dependencies)
                    jobRunner.appDidBecomeActive(using: dependencies)
                }
                
                // MARK: ------ by adding
                context("by adding") {
                    // MARK: -------- does not start until after the db transaction completes
                    it("does not start until after the db transaction completes") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        
                        mockStorage.write { db in
                            jobRunner.add(db, job: job1, canStartJob: true, using: dependencies)
                            
                            expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        }
                        
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                    }
                }
                
                // MARK: ------ with job dependencies
                context("with job dependencies") {
                    // MARK: -------- starts dependencies first
                    it("starts dependencies first") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        job2 = job2.with(details: TestDetails(completeTime: 2))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            try job2.insert(db)
                            try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                    }
                    
                    // MARK: -------- removes the initial job from the queue
                    it("removes the initial job from the queue") {
                        job1 = job1.with(details: TestDetails(completeTime: 1))
                        job2 = job2.with(details: TestDetails(completeTime: 2))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            try job2.insert(db)
                            try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the initial job is removed from the queue
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
                    }
                    
                    // MARK: -------- starts the initial job when the dependencies succeed
                    it("starts the initial job when the dependencies succeed") {
                        job1 = job1.with(details: TestDetails(completeTime: 2))
                        job2 = job2.with(details: TestDetails(completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            try job2.insert(db)
                            try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
                        
                        // Make sure the initial job starts
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys))
                            .to(equal([100]))
                    }
                    
                    // MARK: -------- does not start the initial job if the dependencies are deferred
                    it("does not start the initial job if the dependencies are deferred") {
                        job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
                        job2 = job2.with(details: TestDetails(result: .deferred, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            try job2.insert(db)
                            try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                    }
                    
                    // MARK: -------- does not start the initial job if the dependencies fail
                    it("does not start the initial job if the dependencies fail") {
                        job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
                        job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            try job2.insert(db)
                            try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                    }
                    
                    // MARK: -------- does not delete the initial job if the dependencies fail
                    it("does not delete the initial job if the dependencies fail") {
                        job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
                        job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            try job2.insert(db)
                            try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(beEmpty())
                        
                        // Stop the queues so it doesn't run out of retry attempts
                        jobRunner.stopAndClearPendingJobs(exceptForVariant: nil, onComplete: nil)
                        
                        // Make sure the jobs still exist
                        expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(2))
                    }
                    
                    // MARK: -------- deletes the initial job if the dependencies permanently fail
                    it("deletes the initial job if the dependencies permanently fail") {
                        job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
                        job2 = job2.with(details: TestDetails(result: .permanentFailure, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            try job2.insert(db)
                            try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(equal([101]))
                        expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
                            .to(beEmpty())
                        
                        // Make sure the jobs were deleted
                        expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
                    }
                }
            }
            
            // MARK: ---- when completing jobs
            context("when completing jobs") {
                beforeEach {
                    jobRunner.appDidFinishLaunching(using: dependencies)
                    jobRunner.appDidBecomeActive(using: dependencies)
                }
                
                // MARK: ------ by succeeding
                context("by succeeding") {
                    // MARK: -------- removes the job from the queue
                    it("removes the job from the queue") {
                        job1 = job1.with(details: TestDetails(result: .success, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                    }
                    
                    // MARK: -------- deletes the job
                    it("deletes the job") {
                        job1 = job1.with(details: TestDetails(result: .success, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        
                        // Make sure the jobs were deleted
                        expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
                    }
                }
                
                // MARK: ------ by deferring
                context("by deferring") {
                    // MARK: -------- reschedules the job to run again later
                    it("reschedules the job to run again later") {
                        job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty())
                        expect {
                            mockStorage.read { db in try Job.select(.details).asRequest(of: Data.self).fetchOne(db) }
                        }.to(equal(
                            try! JSONEncoder()
                                .with(outputFormatting: .sortedKeys)
                                .encode(TestDetails(result: .deferred, completeTime: 3))
                        ))
                    }
                    
                    // MARK: -------- does not delete the job
                    it("does not delete the job") {
                        job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty())
                        
                        // Make sure the jobs were deleted
                        expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0))
                    }
                    
                    // MARK: -------- fails the job if it is deferred too many times
                    it("fails the job if it is deferred too many times") {
                        job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure it runs
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        
                        // Progress the time
                        dependencies.stepForwardInTime()
                        
                        // Make sure it finishes once
                        expect(jobRunner.jobInfoFor(state: .running))
                            .to(equal([
                                100: JobRunner.JobInfo(
                                    variant: .messageSend,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(result: .deferred, completeTime: 3))
                                )
                            ]))
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        
                        // Progress the time
                        dependencies.stepForwardInTime()
                        
                        // Make sure it finishes twice
                        expect(jobRunner.jobInfoFor(state: .running))
                            .to(equal([
                                100: JobRunner.JobInfo(
                                    variant: .messageSend,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(result: .deferred, completeTime: 5))
                                )
                            ]))
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        
                        // Progress the time
                        dependencies.stepForwardInTime()
                        
                        // Make sure it's finishes the last time
                        expect(jobRunner.jobInfoFor(state: .running))
                            .to(equal([
                                100: JobRunner.JobInfo(
                                    variant: .messageSend,
                                    threadId: nil,
                                    interactionId: nil,
                                    detailsData: try! JSONEncoder()
                                        .with(outputFormatting: .sortedKeys)
                                        .encode(TestDetails(result: .deferred, completeTime: 7))
                                )
                            ]))
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        
                        // Make sure the job was marked as failed
                        expect(mockStorage.read { db in try Job.fetchOne(db, id: 100)?.failureCount }).to(equal(1))
                    }
                }
                
                // MARK: ------ by failing
                context("by failing") {
                    // MARK: -------- removes the job from the queue
                    it("removes the job from the queue") {
                        job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                    }
                    
                    // MARK: -------- does not delete the job
                    it("does not delete the job") {
                        job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        
                        // Make sure the jobs were deleted
                        expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0))
                    }
                }
                
                // MARK: ------ by permanently failing
                context("by permanently failing") {
                    // MARK: -------- removes the job from the queue
                    it("removes the job from the queue") {
                        job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                    }
                    
                    // MARK: -------- deletes the job
                    it("deletes the job") {
                        job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1))
                        
                        mockStorage.write { db in
                            try job1.insert(db)
                            
                            jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
                        }
                        
                        // Make sure the dependency is run
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
                        
                        // Make sure there are no running jobs
                        dependencies.stepForwardInTime()
                        expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
                        
                        // Make sure the jobs were deleted
                        expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
                    }
                }
            }
        }
    }
}

// MARK: - Test Types

fileprivate struct TestDetails: Codable {
    enum ResultType: Codable {
        case success
        case failure
        case permanentFailure
        case deferred
    }
    
    public let result: ResultType
    public let completeTime: Int
    public let intValue: Int64
    public let stringValue: String
    
    init(
        result: ResultType = .success,
        completeTime: Int = 0,
        intValue: Int64 = 100,
        stringValue: String = "200"
    ) {
        self.result = result
        self.completeTime = completeTime
        self.intValue = intValue
        self.stringValue = stringValue
    }
}

fileprivate struct InvalidDetails: Codable {
    func encode(to encoder: Encoder) throws { throw HTTPError.parsingFailed }
}

fileprivate enum TestJob: JobExecutor {
    static let maxFailureCount: Int = 1
    static let requiresThreadId: Bool = false
    static let requiresInteractionId: Bool = false
    
    static func run(
        _ job: Job,
        queue: DispatchQueue,
        success: @escaping (Job, Bool, Dependencies) -> (),
        failure: @escaping (Job, Error?, Bool, Dependencies) -> (),
        deferred: @escaping (Job, Dependencies) -> (),
        using dependencies: Dependencies
    ) {
        guard
            let detailsData: Data = job.details,
            let details: TestDetails = try? JSONDecoder().decode(TestDetails.self, from: detailsData)
        else { return success(job, true, dependencies) }
        
        let completeJob: () -> () = {
            // Need to increase the 'completeTime' and 'nextRunTimestamp' to prevent the job
            // from immediately being run again or immediately completing afterwards
            let updatedJob: Job = job
                .with(nextRunTimestamp: TimeInterval(details.completeTime + 1))
                .with(
                    details: TestDetails(
                        result: details.result,
                        completeTime: (details.completeTime + 2),
                        intValue: details.intValue,
                        stringValue: details.stringValue
                    )
                )!
            dependencies.storage.write { db in try _ = updatedJob.saved(db) }
            
            switch details.result {
                case .success: success(job, true, dependencies)
                case .failure: failure(job, nil, false, dependencies)
                case .permanentFailure: failure(job, nil, true, dependencies)
                case .deferred: deferred(updatedJob, dependencies)
            }
        }
        
        guard dependencies.fixedTime < details.completeTime else {
            return queue.async(using: dependencies) {
                completeJob()
            }
        }
        
        dependencies.asyncExecutions.appendTo(details.completeTime) {
            queue.async(using: dependencies) {
                completeJob()
            }
        }
    }
}
