packages feed

freckle-http-0.2.0.0: tests/Freckle/App/Http/CacheSpec.hs

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE NoFieldSelectors #-}

module Freckle.App.Http.CacheSpec
  ( spec
  ) where

import Prelude

import Codec.Compression.GZip qualified as GZip
import Control.Lens ((.~), (<>~))
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.State (StateT, execStateT)
import Data.Aeson (FromJSON, eitherDecode)
import Data.ByteString.Lazy qualified as BSL
import Data.Foldable (for_)
import Data.Function ((&))
import Data.Functor (void)
import Data.HashMap.Strict qualified as HashMap
import Data.Time (addUTCTime, getCurrentTime)
import Freckle.App.Http
import Freckle.App.Http.Cache
import Freckle.App.Http.Cache.State
import Freckle.App.Test.Http
import Network.HTTP.Types.Header
  ( hAcceptEncoding
  , hAcceptLanguage
  , hAge
  , hCacheControl
  , hContentEncoding
  , hETag
  , hExpires
  , hIfNoneMatch
  , hVary
  )
import Network.HTTP.Types.Status
  ( status100
  , status304
  , status307
  , status400
  , status503
  )
import Test.Hspec (Spec, context, describe, it)
import Test.Hspec.Expectations.Json.Lifted (shouldMatchJson)
import Test.Hspec.Expectations.Lifted

type CacheSettings = HttpCacheSettings (StateT Cache IO) CachedResponse

spec :: Spec
spec = do
  describe "httpCached" $ do
    it "caches successful GET requests" $ do
      let
        stubs =
          [ "https://example.com/1" & bodyL .~ "Hello\n"
          , "https://example.com/2" & bodyL .~ "World\n"
          ]

        req1 = parseRequest_ "https://example.com/1"
        req2 = parseRequest_ "https://example.com/2"

      cache <- execCached $ do
        requestBodyCached settings stubs req1 `shouldReturn` "Hello\n"
        requestBodyCached settings stubs req2 `shouldReturn` "World\n"

        -- No stubs, so these would fail if not cached
        requestBodyCached settings [] req1 `shouldReturn` "Hello\n"
        requestBodyCached settings [] req2 `shouldReturn` "World\n"

      cache.map `shouldSatisfy` ((== 2) . HashMap.size)

    it "evicts stale caches" $ do
      let
        stubs =
          [ "https://example.com/1"
              & headersL <>~ [(hCacheControl, "max-age=2")]
              & bodyL .~ "Hi\n"
          ]

        -- On the request that we expect to evict, we'll use this so that we
        -- don't store a cache from that and we can observe the eviction.
        stubsNoStore =
          [ "https://example.com/1"
              & headersL <>~ [(hCacheControl, "no-store")]
              & bodyL .~ "Hi\n"
          ]

        req = parseRequest_ "https://example.com/1"

      cache <- execCached $ do
        requestBodyCached settings stubs req `shouldReturn` "Hi\n"

        -- Cached, no requests made
        requestBodyCached settings [] req `shouldReturn` "Hi\n"

        -- Expired, trigger eviction
        requestBodyCached settingsFuture stubsNoStore req `shouldReturn` "Hi\n"

      cache.map `shouldSatisfy` ((== 0) . HashMap.size)

    it "incorporates Vary headers into the cache key" $ do
      let
        stubs =
          [ "https://example.com/1"
              & matchL <>~ MatchHeader (hAcceptLanguage, "en")
              & bodyL .~ "Hello\n"
          , "https://example.com/1"
              & matchL <>~ MatchHeader (hAcceptLanguage, "es")
              & bodyL .~ "Hola\n"
          , "https://example.com/2"
              & matchL <>~ MatchHeader (hAcceptLanguage, "en")
              & bodyL .~ "World\n"
          , "https://example.com/2"
              & matchL <>~ MatchHeader (hAcceptLanguage, "es")
              & bodyL .~ "Mundo\n"
          ]

        reqEn1 =
          parseRequest_ "https://example.com/1"
            & addRequestHeader hAcceptLanguage "en"
            & addRequestHeader hVary "Accept, Accept-Language"
        reqEn2 =
          parseRequest_ "https://example.com/2"
            & addRequestHeader hAcceptLanguage "en"
            & addRequestHeader hVary "Accept, Accept-Language"
        reqEs1 =
          parseRequest_ "https://example.com/1"
            & addRequestHeader hAcceptLanguage "es"
            & addRequestHeader hVary "Accept, Accept-Language"
        reqEs2 =
          parseRequest_ "https://example.com/2"
            & addRequestHeader hAcceptLanguage "es"
            & addRequestHeader hVary "Accept, Accept-Language"

      cache <- execCached $ do
        requestBodyCached settings stubs reqEn1 `shouldReturn` "Hello\n"
        requestBodyCached settings stubs reqEn2 `shouldReturn` "World\n"
        requestBodyCached settings stubs reqEs1 `shouldReturn` "Hola\n"
        requestBodyCached settings stubs reqEs2 `shouldReturn` "Mundo\n"

        -- No stubs, so these would fail if not cached
        requestBodyCached settings [] reqEn1 `shouldReturn` "Hello\n"
        requestBodyCached settings [] reqEn2 `shouldReturn` "World\n"
        requestBodyCached settings [] reqEs1 `shouldReturn` "Hola\n"
        requestBodyCached settings [] reqEs2 `shouldReturn` "Mundo\n"

      cache.map `shouldSatisfy` ((== 4) . HashMap.size)

    context "compression" $ do
      it "caches gzipped responses as gzipped" $ do
        let
          gzipped = GZip.compress "Hi (zipped)\n"

          stubs =
            [ "https://example.com/1"
                & matchL <>~ MatchHeader (hAcceptEncoding, "gzip")
                & headersL <>~ [(hContentEncoding, "gzip")]
                & bodyL .~ gzipped
            , "https://example.com/1"
                & bodyL .~ "Hi (not zipped)\n"
            ]

          req =
            parseRequest_ "https://example.com/1"
              & addRequestHeader hVary "accept-encoding"
          reqGzipped =
            parseRequest_ "https://example.com/1"
              & addRequestHeader hVary "accept-encoding"
              & addRequestHeader hAcceptEncoding "gzip"
          reqGzippedAsIs =
            parseRequest_ "https://example.com/1"
              & addRequestHeader hVary "accept-encoding"
              & addRequestHeader hAcceptEncoding "gzip"
              & disableRequestDecompress

        cache <- execCached $ do
          requestBodyCached settings stubs req `shouldReturn` "Hi (not zipped)\n"
          requestBodyCached settings stubs reqGzipped `shouldReturn` "Hi (zipped)\n"
          requestBodyCached settings stubs reqGzippedAsIs `shouldReturn` gzipped

        cache.map `shouldSatisfy` ((== 2) . HashMap.size)

        -- We don't want to expose the constructor, but we do want to verify the
        -- cache contains the gzipped form.
        map (show . getResponseBody . (.response) . snd) (HashMap.toList cache.map)
          `shouldMatchList` [ "PotentiallyGzipped {unwrap = \"Hi (not zipped)\\n\"}" :: String
                            , "PotentiallyGzipped {unwrap = " <> show gzipped <> "}"
                            ]

      it "handles large gzip responses correctly" $ do
        bs <- BSL.readFile "tests/files/constructed-responses.gzip"
        val <- expectDecode $ GZip.decompress bs

        let
          stubs =
            [ "https://example.com/1"
                & matchL <>~ MatchHeader (hAcceptEncoding, "gzip")
                & headersL <>~ [(hContentEncoding, "gzip")]
                & bodyL .~ bs
            ]

          req =
            parseRequest_ "https://example.com/1"
              & addRequestHeader hVary "accept-encoding"
              & addRequestHeader hAcceptEncoding "gzip"

        void $ execCached $ do
          actual <- expectDecode =<< requestBodyCached settings stubs req
          actual `shouldMatchJson` val

    context "Handling ETag" $ do
      let etag = "W/\"99\""

      it "uses cached response and doesn't evict on 304 from If-None-Match" $ do
        let stubs =
              [ "https://example.com/1"
                  & matchL <>~ MatchHeader (hIfNoneMatch, etag)
                  & statusL .~ status304
                  & bodyL .~ "<ignore me>\n"
              , "https://example.com/1"
                  & headersL <>~ [(hCacheControl, "max-age=-1")]
                  & headersL <>~ [(hETag, etag)]
                  & bodyL .~ "Original body\n"
              ]

        cache <- execCached $ do
          let req = parseRequest_ "https://example.com/1"
          requestBodyCached settings stubs req `shouldReturn` "Original body\n"
          requestBodyCached settings stubs req `shouldReturn` "Original body\n"

        cache.map `shouldSatisfy` ((== 1) . HashMap.size)

      it "updates cached response on 304 from If-None-Match" $ do
        let stubs =
              [ "https://example.com/1"
                  & matchL <>~ MatchHeader (hIfNoneMatch, etag)
                  & statusL .~ status304
                  & headersL <>~ [(hCacheControl, "max-age=120")]
                  & bodyL .~ "<ignore me>\n"
              , "https://example.com/1"
                  & headersL <>~ [(hCacheControl, "max-age=-1")]
                  & headersL <>~ [(hETag, etag)]
                  & bodyL .~ "Original body\n"
              ]

        cache <- execCached $ do
          let req = parseRequest_ "https://example.com/1"
          requestBodyCached settings stubs req `shouldReturn` "Original body\n"
          requestBodyCached settings stubs req `shouldReturn` "Original body\n"

        cache.map `shouldSatisfy` ((== 1) . HashMap.size)
        map (.ttl) (HashMap.elems cache.map) `shouldBe` [120]

      it "evicts a stale response after trying If-None-Match" $ do
        let stubs =
              [ "https://example.com/1"
                  & matchL <>~ MatchHeader (hIfNoneMatch, etag)
                  & headersL <>~ [(hCacheControl, "no-store")]
                  & bodyL .~ "Newer body\n"
              , "https://example.com/1"
                  & headersL <>~ [(hCacheControl, "max-age=-1")]
                  & headersL <>~ [(hETag, etag)]
                  & bodyL .~ "Original body\n"
              ]

        cache <- execCached $ do
          let req = parseRequest_ "https://example.com/1"
          requestBodyCached settings stubs req `shouldReturn` "Original body\n"
          requestBodyCached settings stubs req `shouldReturn` "Newer body\n"

        cache.map `shouldSatisfy` ((== 0) . HashMap.size)

    context "setting TTL" $ do
      let req = parseRequest_ "https://example.com"

      it "sets TTL based on max-age" $ do
        let stubs =
              [ "https://example.com"
                  & headersL <>~ [(hCacheControl, "max-age=42")]
              ]

        cache <- execCached $ requestBodyCached settings stubs req
        map (.ttl) (HashMap.elems cache.map) `shouldBe` [42]

      it "sets TTL based on max-age + Age" $ do
        let stubs =
              [ "https://example.com"
                  & headersL <>~ [(hAge, "78000"), (hCacheControl, "max-age=78250")]
              ]

        cache <- execCached $ requestBodyCached settings stubs req
        map (.ttl) (HashMap.elems cache.map) `shouldBe` [250]

      it "sets TTL based on Expires" $ do
        let
          expDate = "Wed, 21 Oct 2015 07:28:00 GMT"
          expSeconds = 1445412480 -- `date --date '{eDate}' +%s`
          stubs = ["https://example.com" & headersL <>~ [(hExpires, expDate)]]

        cache <- execCached $ requestBodyCached settings stubs req
        map (.ttl) (HashMap.elems cache.map) `shouldBe` [expSeconds]

    context "un-cacheable requests" $ do
      it "does not cache if told not to" $ do
        let req = parseRequest_ "https://example.com"
        cache <- execCached $ requestBodyCached settingsDisabled stubAnything req
        cache.map `shouldSatisfy` ((== 0) . HashMap.size)

      it "does not cache non-GET methods" $ do
        let req = parseRequest_ "POST https://example.com"
        cache <- execCached $ requestBodyCached settings stubAnything req
        cache.map `shouldSatisfy` ((== 0) . HashMap.size)

      it "does not cache no-store" $ do
        let req =
              parseRequest_ "https://example.com"
                & addRequestHeader hCacheControl "no-store"
        cache <- execCached $ requestBodyCached settings stubAnything req
        cache.map `shouldSatisfy` ((== 0) . HashMap.size)

      it "does not cache private in a shared cache" $ do
        let req =
              parseRequest_ "https://example.com"
                & addRequestHeader hCacheControl "private"
        cache <- execCached $ requestBodyCached settingsShared stubAnything req
        cache.map `shouldSatisfy` ((== 0) . HashMap.size)

    context "un-cacheable responses" $ do
      let req = parseRequest_ "https://example.com"

      it "does not cache no-store" $ do
        let stubs =
              [ "https://example.com"
                  & headersL <>~ [(hCacheControl, "no-store, max-age=0, public")]
              ]

        cache <- execCached $ requestBodyCached settings stubs req
        cache.map `shouldSatisfy` ((== 0) . HashMap.size)

      it "does not cache private in a shared cache" $ do
        let stubs =
              [ "https://example.com"
                  & headersL <>~ [(hCacheControl, "max-age=0, private")]
              ]

        cache <- execCached $ requestBodyCached settingsShared stubs req
        cache.map `shouldSatisfy` ((== 0) . HashMap.size)

      for_ [status100, status307, status400, status503] $ \s -> do
        it ("does not cache un-cacheable status " <> show (statusCode s)) $ do
          let stubs = ["https://example.com" & statusL .~ s]

          cache <- execCached $ requestBodyCached settingsShared stubs req
          cache.map `shouldSatisfy` ((== 0) . HashMap.size)

execCached :: StateT Cache IO a -> IO Cache
execCached = flip execStateT mempty

requestBodyCached
  :: CacheSettings
  -> [HttpStub]
  -> Request
  -> StateT Cache IO BSL.ByteString
requestBodyCached ss stubs req =
  getResponseBody <$> httpCached ss (httpStubbed stubs) req

settings :: CacheSettings
settings = stateHttpCacheSettings

settingsDisabled :: CacheSettings
settingsDisabled =
  settings
    { cacheable = const False
    }

settingsShared :: CacheSettings
settingsShared =
  settings
    { shared = True
    }

settingsFuture :: CacheSettings
settingsFuture =
  settings
    { getCurrentTime = liftIO $ addUTCTime 5 <$> getCurrentTime
    }

stubAnything :: [HttpStub]
stubAnything = [httpStub "Anything" MatchAnything]

expectDecode :: (HasCallStack, MonadIO m, FromJSON a) => BSL.ByteString -> m a
expectDecode bs = case eitherDecode bs of
  Left err -> do
    expectationFailure $
      mconcat
        [ "Expected input to decode as JSON"
        , "\nInput:  " <> show bs
        , "\nErrors: " <> err
        ]
    error "<unreachable>"
  Right a -> pure a