packages feed

polling-cache-0.0.1.0: test/PollingCacheSpec.hs

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE RecordWildCards #-}

module PollingCacheSpec (spec) where

import Control.Concurrent
import Control.Exception
import Control.Monad
import Control.Monad.Trans
import Control.Monad.Trans.State.Strict
import Data.Cache.Polling
import Data.Either (isRight)
import Data.Functor ((<&>))
import Data.Maybe (fromMaybe)
import Data.Time
import Data.Time.Calendar.OrdinalDate
import Test.Hspec

data TestState = TestState
  { now :: UTCTime,
    fuzzing :: Maybe Int,
    numIterations :: Int,
    maxIterations :: Int
  }
  deriving (Show)

type TestCache a = StateT TestState IO a

instance MonadCache (StateT TestState IO) where
  currentTime = gets now
  delay us = do
    let diff = realToFrac us
    st@TestState {..} <- get
    put $ st {now = addUTCTime diff now}
  randomize _ = do
    fz <- gets fuzzing
    return $ fromMaybe 0 fz
  repeatedly act = go
    where
      step st@TestState {..} = st {numIterations = numIterations + 1}
      go = do
        withStateT step act
        TestState {..} <- get
        when (numIterations < maxIterations) go
  newThread act = act >> lift myThreadId
  killCache _ = return ()

testDay :: Day
testDay = fromOrdinalDate 0 0

testTime :: DiffTime -> UTCTime
testTime = UTCTime testDay

testState :: Int -> TestState
testState = TestState (testTime 0) Nothing 0

testOptions :: Int -> FailureMode -> CacheOptions a
testOptions ms fm = basicOptions (DelayForMicroseconds ms) fm

testState' :: Int -> Int -> TestState
testState' fuzz = TestState (testTime 0) (Just fuzz) 0

testOptions' :: DelayMode a -> FailureMode -> TestState -> CacheOptions a
testOptions' d f s = (basicOptions d f) {delayFuzzing = (fuzzing s)}

data TestException = TestException deriving (Show)

instance Exception TestException

alwaysSucceeds :: TestCache Int
alwaysSucceeds = return 123

alwaysSucceedsIO :: IO Int
alwaysSucceedsIO = return 123

alwaysFails :: TestCache Int
alwaysFails = throw TestException

secondPassFails :: TestCache Int
secondPassFails = do
  TestState {..} <- get
  when (numIterations == 2) $ throw TestException
  return 234

secondPassSucceeds :: TestCache Int
secondPassSucceeds = do
  iteration <- gets numIterations
  if iteration == 2
    then return 12190
    else throw TestException

transientFailure :: Int -> Int -> TestCache Int
transientFailure startFailing stopFailing = do
  iteration <- gets numIterations
  if iteration >= startFailing && iteration <= stopFailing
    then throw TestException
    else return 81590

spec :: Spec
spec = do
  basicFunctionalitySpec
  ignoreSpec
  evictImmediatelySpec
  evictAfterTimeSpec
  delayDynamicallySpec
  delayDynamicallyWithBoundsSpec
  fuzzingSpec

basicFunctionalitySpec :: Spec
basicFunctionalitySpec =
  context "Basic functionality" $ do
    describe "background processing thread" $ do
      let testCache = newPollingCache (testOptions 100 Ignore) alwaysSucceeds
      it "will suspend background execution for the time span specified by the user" $ do
        cache <- evalStateT testCache $ testState 4
        val <- cachedValues cache
        val `shouldBe` Right (123, testTime 300)

    describe "after stopping cache" $ do
      it "will no longer return valid values" $ do
        cache <- newPollingCache (testOptions 100 Ignore) alwaysSucceedsIO
        stopPolling cache
        val <- cachedValues cache
        val `shouldBe` Left Stopped

    describe "IO actions" $ do
      it "will be executed in a background thread" $ do
        cache <- newPollingCache (testOptions 1 Ignore) alwaysSucceedsIO
        threadDelay 100
        val <- cachedValues cache
        val `shouldSatisfy` isRight
        stopPolling cache

      it "will execute repeatedly" $ do
        cache <- newPollingCache (testOptions 1 Ignore) alwaysSucceedsIO
        threadDelay 100
        firstVal <- cachedValues cache
        threadDelay 100
        secondVal <- cachedValues cache
        (firstVal <&> snd) `shouldNotBe` (secondVal <&> snd)
        stopPolling cache

ignoreSpec :: Spec
ignoreSpec =
  context "Ignoring faliure" $ do
    describe "without succeeding" $ do
      let testCache = newPollingCache (testOptions 10 Ignore) alwaysFails
      it "will never load a value" $ do
        cache <- evalStateT testCache $ testState 10
        val <- cachedValues cache
        val `shouldBe` Left NotYetLoaded

    describe "after succeeding once" $ do
      let testCache = newPollingCache (testOptions 10 Ignore) secondPassSucceeds
      it "will always return the successful result" $ do
        cache <- evalStateT testCache $ testState 10
        val <- cachedValues cache
        val `shouldBe` Right (12190, testTime 10)

evictImmediatelySpec :: Spec
evictImmediatelySpec =
  context "Evicting immediately upon failure" $ do
    describe "with a constant generator" $ do
      let testCache = newPollingCache (testOptions 10 EvictImmediately) alwaysSucceeds
      it "will continually return the generated value" $ do
        (cache, st) <- runStateT testCache $ testState 1
        val <- cachedValues cache
        val `shouldBe` Right (123, testTime 0)
        nextCache <- evalStateT testCache st
        nextVal <- cachedValues nextCache
        nextVal `shouldBe` Right (123, testTime 10)

    describe "with a sporadic failure" $ do
      let testCache = newPollingCache (testOptions 10 EvictImmediately) secondPassFails
      it "will report the failure" $ do
        cache <- evalStateT testCache $ testState 2
        val <- cachedValues cache
        val `shouldBe` (Left . LoadFailed $ testTime 10)

      it "will succeed after failure" $ do
        cache <- evalStateT testCache $ testState 10
        val <- cachedValues cache
        val `shouldBe` Right (234, testTime 90)

    describe "with a persistent failure" $ do
      let testCache = newPollingCache (testOptions 10 EvictImmediately) alwaysFails
      it "will not update the original failure time" $ do
        cache <- evalStateT testCache $ testState 10
        val <- cachedValues cache
        val `shouldBe` (Left . LoadFailed $ testTime 0)

evictAfterTimeSpec :: Spec
evictAfterTimeSpec =
  context "Evicting after a time interval" $ do
    describe "with a transient failure" $ do
      let testCache = newPollingCache (testOptions 10 (EvictAfterTime 30)) (transientFailure 3 8)
      it "will not report the failure before the cut-off period" $ do
        cache <- evalStateT testCache $ testState 2
        val <- cachedValues cache
        val `shouldBe` Right (81590, testTime 10)

      it "will report the failure after the cut-off period" $ do
        cache <- evalStateT testCache $ testState 5
        val <- cachedValues cache
        val `shouldBe` (Left . LoadFailed $ testTime 40)

      it "will not overwrite the failure time for successive failures" $ do
        cache <- evalStateT testCache $ testState 7
        val <- cachedValues cache
        val `shouldBe` (Left . LoadFailed $ testTime 40)

      it "will report success after the failure subsides" $ do
        cache <- evalStateT testCache $ testState 10
        val <- cachedValues cache
        val `shouldBe` Right (81590, testTime 90)

testDynamicDelay :: Int -> Either SomeException a -> Int
testDynamicDelay = const

delayDynamicallySpec :: Spec
delayDynamicallySpec = context "Dynamic delay" $ do
  let ts = testState 2
  let testCache = newPollingCache (testOptions' (DelayDynamically $ testDynamicDelay 15) EvictImmediately ts)
  describe "with a successful result" $ do
    it "will delay for the user's supplied time span" $ do
      cache <- evalStateT (testCache alwaysSucceeds) ts
      val <- cachedValues cache
      val `shouldBe` Right (123, testTime 15)
  describe "with an exception" $ do
    it "will delay for the user's supplied time span" $ do
      cache <- evalStateT (testCache secondPassFails) ts
      val <- cachedValues cache
      val `shouldBe` (Left . LoadFailed $ testTime 15)

delayDynamicallyWithBoundsSpec :: Spec
delayDynamicallyWithBoundsSpec = context "Dynamic delay with bounds" $ do
  let ts = testState 2
  let testCache = newPollingCache (testOptions' (DelayDynamicallyWithBounds (5, 15) $ testDynamicDelay 10) EvictImmediately ts)
  describe "with a successful result" $ do
    it "will delay for the user's supplied time span" $ do
      cache <- evalStateT (testCache alwaysSucceeds) ts
      val <- cachedValues cache
      val `shouldBe` Right (123, testTime 10)
  describe "with an exception" $ do
    it "will delay for the user's supplied time span" $ do
      cache <- evalStateT (testCache secondPassFails) ts
      val <- cachedValues cache
      val `shouldBe` (Left . LoadFailed $ testTime 10)
  describe "with a dynamic delay less than the lower bound" $ do
    let c = newPollingCache (testOptions' (DelayDynamicallyWithBounds (5, 15) $ testDynamicDelay 1) EvictImmediately ts)
    it "will delay for the lower bound" $ do
      cache <- evalStateT (c alwaysSucceeds) ts
      val <- cachedValues cache
      val `shouldBe` Right (123, testTime 5)
  describe "with a dynamic delay greater than the upper bound" $ do
    let c = newPollingCache (testOptions' (DelayDynamicallyWithBounds (5, 15) $ testDynamicDelay 25) EvictImmediately ts)
    it "will delay for upper bound" $ do
      cache <- evalStateT (c alwaysSucceeds) ts
      val <- cachedValues cache
      val `shouldBe` Right (123, testTime 15)

fuzzingSpec :: Spec
fuzzingSpec = context "Fuzzing" $ do
  let ts = testState' 7 2
  describe "with a static delay" $ do
    let testCache = newPollingCache (testOptions' (DelayForMicroseconds 11) EvictImmediately ts)
    it "will modify the delay period" $ do
      cache <- evalStateT (testCache alwaysSucceeds) ts
      val <- cachedValues cache
      val `shouldBe` Right (123, testTime 18)
  describe "with a dynamic delay" $ do
    let testCache = newPollingCache (testOptions' (DelayDynamically $ testDynamicDelay 17) EvictImmediately ts)
    it "will modify the delay period" $ do
      cache <- evalStateT (testCache alwaysSucceeds) ts
      val <- cachedValues cache
      val `shouldBe` Right (123, testTime 24)