diff --git a/.ghci b/.ghci
new file mode 100644
--- /dev/null
+++ b/.ghci
@@ -0,0 +1,6 @@
+:set -isrc -idist/build/autogen
+:set -Wall
+:set -XOverloadedStrings
+:l Webcrank
+:m + Webcrank
+:set prompt ">> "
diff --git a/.gitignore b/.gitignore
new file mode 100644
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+dist
+cabal-dev
+*.o
+*.hi
+*.chi
+*.chs.h
+.virthualenv
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,46 @@
+# From https://github.com/hvr/multi-ghc-travis
+
+# NB: don't set `language: haskell` here
+
+# The following enables several GHC versions to be tested; often it's enough to test only against the last release in a major GHC version. Feel free to omit lines listings versions you don't need/want testing for.
+env:
+ - CABALVER=1.18 GHCVER=7.6.3
+ - CABALVER=1.18 GHCVER=7.8.3
+ - CABALVER=1.22 GHCVER=7.10.1
+ - CABALVER=head GHCVER=head   # see section about GHC HEAD snapshots
+
+matrix:
+  allow_failures:
+   - env: CABALVER=head GHCVER=head
+
+# Note: the distinction between `before_install` and `install` is not important.
+before_install:
+ - travis_retry sudo add-apt-repository -y ppa:hvr/ghc
+ - travis_retry sudo apt-get update
+ - travis_retry sudo apt-get install cabal-install-$CABALVER ghc-$GHCVER # see note about happy/alex
+ - export PATH=/opt/ghc/$GHCVER/bin:/opt/cabal/$CABALVER/bin:$PATH
+
+install:
+ - cabal --version
+ - echo "$(ghc --version) [$(ghc --print-project-git-commit-id 2> /dev/null || echo '?')]"
+ - travis_retry cabal update
+ - cabal install --only-dependencies --enable-tests --enable-benchmarks
+
+# Here starts the actual work to be performed for the package under test; any command which exits with a non-zero exit code causes the build to fail.
+script:
+ - if [ -f configure.ac ]; then autoreconf -i; fi
+ - cabal configure --enable-tests --enable-benchmarks -v2  # -v2 provides useful information for debugging
+ - cabal build   # this builds all libraries and executables (including tests/benchmarks)
+ - cabal test
+ - cabal check
+ - cabal sdist   # tests that a source-distribution can be generated
+
+# The following scriptlet checks that the resulting source distribution can be built & installed
+ - export SRC_TGZ=$(cabal info . | awk '{print $2 ".tar.gz";exit}') ;
+   cd dist/;
+   if [ -f "$SRC_TGZ" ]; then
+      cabal install --force-reinstalls "$SRC_TGZ";
+   else
+      echo "expected '$SRC_TGZ' not found";
+      exit 1;
+   fi
diff --git a/HLint.hs b/HLint.hs
new file mode 100644
--- /dev/null
+++ b/HLint.hs
@@ -0,0 +1,5 @@
+import "hint" HLint.Default
+import "hint" HLint.Generalise
+
+ignore "Use import/export shortcut"
+
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+# Webcrank [![TravisCI](https://travis-ci.org/webcrank/webcrank.hs.svg)](https://travis-ci.org/webcrank/webcrank.hs) [![Hackage](https://img.shields.io/hackage/v/webcrank.svg?style=flat)](https://hackage.haskell.org/package/webcrank) [![Dependencies](https://img.shields.io/hackage-deps/v/webcrank.svg?style=flat)](http://packdeps.haskellers.com/feed?needle=webcrank)
+
+Webcrank is an ambitious project to build a fast, reliable and
+__correct__, HTTP toolkit.
+
+This project forms the core of the haskell implementation.
+
+See <https://github.com/webcrank/webcrank> for more details.
+
diff --git a/test/DecisionTests.hs b/test/DecisionTests.hs
new file mode 100644
--- /dev/null
+++ b/test/DecisionTests.hs
@@ -0,0 +1,750 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TupleSections #-}
+
+module DecisionTests where
+
+import Control.Applicative
+import Control.Lens
+import Control.Monad.Catch.Pure
+import Control.Monad.Reader
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Char8 as B
+import qualified Data.ByteString.Lazy as LB
+import qualified Data.HashMap.Strict as HashMap
+import Data.Monoid
+import Network.HTTP.Date
+import Network.HTTP.Media
+import Network.HTTP.Types
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import Webcrank
+import Webcrank.Internal
+import Webcrank.Internal.DecisionCore
+import Webcrank.Internal.Halt
+
+import TestServerAPI
+
+decisionTests :: TestTree
+decisionTests = testGroup "Decision tests"
+  [ b13Tests
+  , b12Tests
+  , b11Tests
+  , b10Tests
+  , b9Tests
+  , b8Tests
+  , b7Tests
+  , b6Tests
+  , b5Tests
+  , b4Tests
+  , b3Tests
+  , c3Tests
+  , c4Tests
+  , d4Tests
+  , d5Tests
+  , e5Tests
+  , e6Tests
+  , f6Tests
+  , f7Tests
+  , g7Tests
+  , g8Tests
+  , g9Tests
+  , g11Tests
+  , h7Tests
+  , h10Tests
+  , h11Tests
+  , h12Tests
+  , i4Tests
+  , i7Tests
+  , i12Tests
+  , i13Tests
+  , j18Tests
+  , k5Tests
+  , k7Tests
+  , k13Tests
+  , l5Tests
+  , l7Tests
+  , l13Tests
+  , l14Tests
+  , l15Tests
+  , l17Tests
+  , m5Tests
+  , m7Tests
+  , m16Tests
+  , m20Tests
+  , n5Tests
+  , n11Tests
+  , n16Tests
+  , o14Tests
+  , o16Tests
+  , o18Tests
+  , o20Tests
+  , p3Tests
+  , p11Tests
+  ]
+
+b13Tests :: TestTree
+b13Tests = decisionTestGroup "b13" "Service available?"
+  [ testCase "True ==> b12" $
+      after' b13 resource req @?= Decision' "b12"
+  , testCase "False ==> 503 Service Unavailable" $
+      let r = resource { serviceAvailable = return False }
+      in after' b13 r req @?= error' serviceUnavailable503
+  ]
+
+b12Tests :: TestTree
+b12Tests = decisionTestGroup "b12" "Known method?" ts where
+  ts = mk <$> ms
+  mk (m, r) = testCase (mconcat [show m, " ==> ", show r]) $
+    after' b12 resource (req { reqMethod = m }) @?= r
+  ms = ("QWERTY", error' notImplemented501) : ((, Decision' "b11") <$> [methodGet, methodHead, methodPost, methodPut, methodDelete, methodTrace, methodConnect, methodOptions])
+
+b11Tests :: TestTree
+b11Tests = decisionTestGroup "b11" "URI too long?"
+  [ testCase "True ==> 414 Request-URI Too Long" $
+      let r = resource { uriTooLong = return True }
+      in after' b11 r req @?= error' requestURITooLong414
+  , testCase "False ==> b11" $
+      after' b11 resource req @?= Decision' "b10"
+ ]
+
+b10Tests :: TestTree
+b10Tests = decisionTestGroup "b10" "Method allowed?"
+  [ testCase "True ==> b9" $
+      after' b10 resource req @?= Decision' "b9"
+  , testCase "False ==> 405 Method Not Allowed" $
+      let rq = req { reqMethod = methodPost }
+      in afterHdrs b10 resource rq @?=
+        (error' methodNotAllowed405, HashMap.singleton hAllow ["GET, HEAD"])
+  ]
+
+b9Tests :: TestTree
+b9Tests = decisionTestGroup "b9" "Malformed?"
+  [ testCase "True ==> 400 Malformed Request" $
+      let r = resource { malformedRequest = return True }
+      in after' b9 r req @?= error' badRequest400
+  , testCase "False ==> b8" $
+      after' b9 resource req @?= Decision' "b8"
+ ]
+
+b8Tests :: TestTree
+b8Tests = decisionTestGroup "b8" "Authorized?"
+  [ testCase "True ==> b7" $
+      after' b8 resource req @?= Decision' "b7"
+  , testCase "False ==> 401 Unauthorized" $
+      let r = resource { isAuthorized = return $ Unauthorized "Basic realm=\"W\"" }
+      in afterHdrs b8 r req @?=
+        (error' unauthorized401, HashMap.singleton hWWWAuthenticate ["Basic realm=\"W\""])
+  ]
+
+b7Tests :: TestTree
+b7Tests = decisionTestGroup "b7" "Forbidden?"
+  [ testCase "True ==> 403 Forbidden" $
+      let r = resource { forbidden = return True }
+      in after' b7 r req @?= error' forbidden403
+  , testCase "False ==> b6" $
+      after' b7 resource req @?= Decision' "b6"
+ ]
+
+b6Tests :: TestTree
+b6Tests = decisionTestGroup "b6" "Okay Content-* Headers?"
+  [ testCase "True ==> b5" $
+      after' b6 resource req @?= Decision' "b5"
+  , testCase "False ==> 501 Not Implemented" $
+    let r = resource { validContentHeaders = return False }
+    in after' b6 r req @?= error' notImplemented501
+  ]
+
+b5Tests :: TestTree
+b5Tests = decisionTestGroup "b5" "Known Content-Type?"
+  [ testCase "True ==> b4" $
+      after' b5 resource req @?= Decision' "b4"
+  , testCase "False ==> 415 Unsupported Media Type" $
+      let r = resource { knownContentType = return False }
+      in after' b5 r req @?= error' unsupportedMediaType415
+  ]
+
+b4Tests :: TestTree
+b4Tests = decisionTestGroup "b4" "Req Entity Too Large?"
+  [ testCase "True ==> b3" $
+      after' b4 resource req @?= Decision' "b3"
+  , testCase "False ==> 413 Request Entity Too Large" $
+      let r = resource { validEntityLength = return False }
+      in after' b4 r req @?= error' requestEntityTooLarge413
+  ]
+
+b3Tests :: TestTree
+b3Tests = decisionTestGroup "b3" "OPTIONS?"
+  [ testCase "True ==> 200 Ok" $
+      let
+        r = resource { options = return [("X-Tra", "Webcrank")] }
+        rq = req { reqMethod = methodOptions }
+      in afterHdrs b3 r rq @?= (Done' ok200, HashMap.singleton "X-Tra" ["Webcrank"])
+  , testCase "False ==> c3" $
+      after' b3 resource req @?= Decision' "c3"
+  ]
+
+c3Tests :: TestTree
+c3Tests = decisionTestGroup "c3" "Accept exists?"
+  [ testCase "True ==> c4 + default media type" $
+      let rq = req { reqHeaders = HashMap.singleton hAccept ["text/html"] }
+      in afterMediaType c3 resource rq @?= (Decision' "c4", "application" // "octet-stream")
+  , testCase "False ==> c4 w/o media type" $
+      let
+        r = resource { contentTypesProvided = return [("text" // "html", return "")] }
+      in afterMediaType c3 r req @?= (Decision' "d4", "text" // "html")
+  ] where
+    afterMediaType s r rq = case after s r rq of
+      (d, rd) -> (d, _reqDataRespMediaType rd)
+
+c4Tests :: TestTree
+c4Tests = decisionTestGroup "c4" "Acceptable media type available?"
+  [ testCase "True ==> d4" $
+      let r = resource { contentTypesProvided = return [("text" // "html", return "")] }
+      in after' (c4 "text/html") r req @?= Decision' "d4"
+  , testCase "False ==> 406 Not Acceptable" $
+      after' (c4 "text/html") resource req @?= Error' notAcceptable406 "No acceptable media type available"
+  ]
+
+d4Tests :: TestTree
+d4Tests = decisionTestGroup "d4" "Accept-Language exists?"
+  [ testCase "True ==> d5" $
+      let rq = req { reqHeaders = HashMap.singleton hAcceptLanguage ["en"] }
+      in after' d4 resource rq @?= Decision' "d5"
+  , testCase "False ==> e5" $
+      after' d4 resource req @?= Decision' "e5"
+  ]
+
+-- just a placeholder
+d5Tests :: TestTree
+d5Tests = decisionTestGroup "d5" "Acceptable language available?"
+  [ testCase "Always ==> e5" $
+      after' (d5 "en") resource req @?= Decision' "e5"
+  ]
+
+e5Tests :: TestTree
+e5Tests = decisionTestGroup "e5" "Accept-Charset exists?"
+  [ testCase "True ==> e6" $
+      let rq = req { reqHeaders = HashMap.singleton hAcceptCharset ["utf-8"] }
+      in after' e5 resource rq @?= Decision' "e6"
+  , testCase "False + no charsets provided ==> f6 w/o charset" $
+      afterCharset e5 resource req @?= (Decision' "f6", Nothing)
+  , testCase "False + charset provided ==> f6 + charset" $
+      let r = resource { charsetsProvided = provideCharsets $ return ("utf-8", id) }
+      in afterCharset e5 r req @?= (Decision' "f6", Just "utf-8")
+  ]
+
+e6Tests :: TestTree
+e6Tests = decisionTestGroup "e6" "Acceptable Charset available?"
+  [ testCase "True ==> f6" $
+      let r = resource { charsetsProvided = provideCharsets $ return ("utf-8", id) }
+      in afterCharset (e6 "utf-8") r req @?= (Decision' "f6", Just "utf-8")
+  , testCase "no charsets provided ==> f6 w/o charset" $
+      afterCharset (e6 "utf-8") resource req @?= (Decision' "f6", Nothing)
+  , testCase "False ==> 406 Not Acceptable" $
+      let r = resource { charsetsProvided = provideCharsets $ return ("wtf-9", id) }
+      in after' (e6 "utf-8") r req @?= Error' notAcceptable406 "No acceptable charset available"
+  ]
+
+f6Tests :: TestTree
+f6Tests = decisionTestGroup "f6" "Accept-Encoding exists?"
+  [ testCase "True ==> f7" $
+      let rq = req { reqHeaders = HashMap.singleton hAcceptEncoding ["gzip"] }
+      in after' f6 resource rq @?= Decision' "f7"
+  , testCase "False ==> g7" $
+      after' f6 resource req @?= Decision' "g7"
+  ]
+
+f7Tests :: TestTree
+f7Tests = decisionTestGroup "f7" "Acceptable encoding available?"
+  [ testCase "True ==> g7 + encoding" $
+      let r = resource { encodingsProvided = return [("gzip", id)] }
+      in afterEnc (f7 "gzip") r req @?= (Decision' "g7", Just "gzip")
+  , testCase "False ==> g7 + identity" $
+      afterEnc (f7 "gzip") resource req @?= (Decision' "g7", Nothing)
+  ] where
+    afterEnc s r rq = case after s r rq of
+      (d, rd) -> (d, _reqDataRespEncoding rd)
+
+g7Tests :: TestTree
+g7Tests = decisionTestGroup "g7" "Resource exists?"
+  [ testCase "True ==> g8" $
+      after' g7 resource req @?= Decision' "g8"
+  , testCase "False ==> h7" $
+      let r = resource { resourceExists = return False }
+      in after' g7 r req @?= Decision' "h7"
+  ]
+
+g8Tests :: TestTree
+g8Tests = decisionTestGroup "g8" "If-Match exists?"
+  [ testCase "True ==> g9" $
+      let rq = req { reqHeaders = HashMap.singleton hIfMatch ["webcrank"] }
+      in after' g8 resource rq @?= Decision' "g9"
+  , testCase "False ==> h10" $
+      after' g8 resource req @?= Decision' "h10"
+  ]
+
+g9Tests :: TestTree
+g9Tests = decisionTestGroup "g9" "If-Match: * exists?"
+  [ testCase "True ==> h10" $
+      after' (g9 "*") resource req @?= Decision' "h10"
+  , testCase "False ==> g11" $
+      let rq = req { reqHeaders = HashMap.singleton hIfMatch ["webcrank"] }
+      in after' (g9 "webcrank") resource rq @?= Decision' "g11"
+  ]
+
+g11Tests :: TestTree
+g11Tests = decisionTestGroup "g11" "ETag in If-Match?"
+  [ testCase "True ==> h10" $
+      let r = resource { generateETag = return $ StrongETag "webcrank" }
+      in after' (g11 "\"webcrank\"") r req @?= Decision' "h10"
+  , testCase "False (no ETag) ==> 412 Precondition Failed" $
+      after' (g11 "\"webcrank\"") resource req @?= error' preconditionFailed412
+  , testCase "False (mismatch ETag) ==> 412 Precondition Failed" $
+      let r = resource { generateETag = return $ StrongETag "webcrank123" }
+      in after' (g11 "\"webcrank456\"") r req @?= error' preconditionFailed412
+  ]
+
+h7Tests :: TestTree
+h7Tests = decisionTestGroup "h7" "If-Match exists? (no existing resource)"
+  [ testCase "True ==> 412 Precondition Failed" $
+      let rq = req { reqHeaders = HashMap.singleton hIfMatch ["webcrank"] }
+      in after' h7 resource rq @?= error' preconditionFailed412
+  , testCase "False ==> i7" $
+      after' h7 resource req @?= Decision' "i7"
+  ]
+
+h10Tests :: TestTree
+h10Tests = decisionTestGroup "h10" "If-Unmodified-Since exists?"
+  [ testCase "True ==> h11" $
+      let rq = req { reqHeaders = HashMap.singleton hIfUnmodifiedSince [dateStr] }
+      in after' h10 resource rq @?= Decision' "h11"
+  , testCase "False ==> i12" $
+      after' h10 resource req @?= Decision' "i12"
+  ]
+
+h11Tests :: TestTree
+h11Tests = decisionTestGroup "h11" "If-Unmodified-Since is valid date?"
+  [ testCase "True ==> h12" $
+      after' (h11 dateStr) resource req @?= Decision' "h12"
+  , testCase "False ==> i12" $
+      after' (h11 "not a date") resource req @?= Decision' "i12"
+  ]
+
+h12Tests :: TestTree
+h12Tests = decisionTestGroup "h12" "Last-Modified > If-Unmodified-Since?"
+  [ testCase "True ==> 412 Precondition Failed" $
+      after' (h12 $ date { hdDay = 10 }) r req @?= error' preconditionFailed412
+  , testCase "False ==> i12" $
+      after' (h12 $ date { hdDay = 20 }) r req @?= Decision' "i12"
+  ] where
+    r = resource { lastModified = return date }
+
+i4Tests :: TestTree
+i4Tests = decisionTestGroup "i4" "Moved permanently? (apply PUT to different URI)"
+  [ testCase "True ==> 301 Moved Permanently" $
+      let
+        loc = "http://example.com/abc"
+        r = resource { movedPermanently = return loc }
+      in afterHdrs i4 r req @?= (Done' movedPermanently301, HashMap.singleton hLocation [loc])
+  , testCase "False ==> p3" $
+      after' i4 resource req @?= Decision' "p3"
+  ]
+
+i7Tests :: TestTree
+i7Tests = decisionTestGroup "i7" "PUT?"
+  [ testCase "True ==> i4" $
+      let rq = req { reqMethod = methodPut }
+      in after' i7 resource rq @?= Decision' "i4"
+  , testCase "False ==> k7" $
+      after' i7 resource req @?= Decision' "k7"
+  ]
+
+i12Tests :: TestTree
+i12Tests = decisionTestGroup "i12" "If-None-Match exists?"
+  [ testCase "True ==> i13" $
+      let rq = req { reqHeaders = HashMap.singleton hIfNoneMatch ["webcrank"] }
+      in after' i12 resource rq @?= Decision' "i13"
+  , testCase "False ==> l13" $
+      after' i12 resource req @?= Decision' "l13"
+  ]
+
+i13Tests :: TestTree
+i13Tests = decisionTestGroup "i13" "If-None-Match: * exists?"
+  [ testCase "True ==> j18" $
+      after' (i13 "*") resource req @?= Decision' "j18"
+  , testCase "False ==> k13" $
+      after' (i13 "webcrank") resource req @?= Decision' "k13"
+  ]
+
+j18Tests :: TestTree
+j18Tests = decisionTestGroup "j18" "GET or HEAD? (resource exists)"
+  [ testCase "True (GET) ==> 304 Not Modified" $
+      after' j18 resource req @?= Done' notModified304
+  , testCase "True (HEAD) ==> 304 Not Modified" $
+      let rq = req { reqMethod = methodHead }
+      in after' j18 resource rq @?= Done' notModified304
+  , testCase "False ==> 412 Precondition Failed" $
+      let rq = req { reqMethod = methodPost }
+      in after' j18 resource rq @?= error' preconditionFailed412
+  ]
+
+k5Tests :: TestTree
+k5Tests = decisionTestGroup "k5" "Moved permanently? (non-PUT variant)"
+  [ testCase "True ==> 301 Moved Permanently" $
+      let
+        uri = "http://example.com/abc"
+        r = resource { movedPermanently = return uri }
+      in afterHdrs k5 r req @?= (Done' movedPermanently301, HashMap.singleton hLocation [uri])
+  , testCase "False ==> l5" $
+      after' k5 resource req @?= Decision' "l5"
+  ]
+
+k7Tests :: TestTree
+k7Tests = decisionTestGroup "k7" "Previously existed?"
+  [ testCase "True ==> k5" $
+      let r = resource { previouslyExisted = return True }
+      in after' k7 r req @?= Decision' "k5"
+  , testCase "False ==> l7" $
+      after' k7 resource req @?= Decision' "l7"
+  ]
+
+k13Tests :: TestTree
+k13Tests = decisionTestGroup "k13" "ETag in If-None-Match?"
+  [ testCase "True ==> j18" $
+      let r = resource { generateETag = return $ StrongETag "webcrank" }
+      in after' (k13 "\"webcrank\"") r req @?= Decision' "j18"
+  , testCase "False (no ETag) ==> l13" $
+      after' (k13 "\"webcrank\"") resource req @?= Decision' "l13"
+  , testCase "False (mismatch ETag) ==> l13" $
+      let r = resource { generateETag = return $ StrongETag "webcrank123" }
+      in after' (k13 "\"webcrank456\"") r req @?= Decision' "l13"
+  ]
+
+l5Tests :: TestTree
+l5Tests = decisionTestGroup "l5" "Moved temporarily?"
+  [ testCase "True ==> 307 Moved Temporarily" $
+      let
+        uri = "http://example.com/abc"
+        r = resource { movedTemporarily = return uri }
+      in afterHdrs l5 r req @?= (Done' temporaryRedirect307, HashMap.singleton hLocation [uri])
+  , testCase "False ==> m5" $
+      after' l5 resource req @?= Decision' "m5"
+  ]
+
+l7Tests :: TestTree
+l7Tests = decisionTestGroup "l7" "POST? (no existing resource)"
+  [ testCase "True ==> m7" $
+      let rq = req { reqMethod = methodPost }
+      in after' l7 resource rq @?= Decision' "m7"
+  , testCase "False ==> 404 Not Found" $
+      after' l7 resource req @?= error' notFound404
+  ]
+
+l13Tests :: TestTree
+l13Tests = decisionTestGroup "l17" "If-Modified-Since exists?"
+  [ testCase "True ==> l14" $
+      let rq = req { reqHeaders = HashMap.singleton hIfModifiedSince [dateStr] }
+      in after' l13 resource rq @?= Decision' "l14"
+  , testCase "False ==> m16" $
+      after' l13 resource req @?= Decision' "m16"
+  ]
+
+l14Tests :: TestTree
+l14Tests = decisionTestGroup "l14" "If-Modified-Since is a valid date?"
+  [ testCase "True ==> l15" $
+      after' (l14 dateStr) resource req @?= Decision' "l15"
+  , testCase "False ==> m16" $
+      after' (l14 "not a date") resource req @?= Decision' "m16"
+  ]
+
+l15Tests :: TestTree
+l15Tests = decisionTestGroup "l15" "If-Modified-Since > Now?"
+  [ testCase "True ==> m16" $
+      let rq = req { reqTime = date { hdDay = 10 } }
+      in after' (l15 date) resource rq @?= Decision' "m16"
+  , testCase "False ==> l17" $
+      after' (l15 date) resource req @?= Decision' "l17"
+  ]
+
+l17Tests :: TestTree
+l17Tests = decisionTestGroup "l17" "Last-Modified > If-Modified-Since?"
+  [ testCase "True ==> m16" $
+      let r = resource { lastModified = return $ date { hdDay = 20 } }
+      in after' (l17 date) r req @?= Decision' "m16"
+  , testCase "No last modified ==> m16" $
+      after' (l17 date) resource req @?= Decision' "m16"
+  , testCase "False ==> 304 Not Modified" $
+      let r = resource { lastModified = return $ date { hdDay = 10 } }
+      in after' (l17 date) r req @?= Done' notModified304
+  ]
+
+m5Tests :: TestTree
+m5Tests = decisionTestGroup "m5" "POST? (resource previously existed)"
+  [ testCase "True ==> n5" $
+      let rq = req { reqMethod = methodPost }
+      in after' m5 resource rq @?= Decision' "n5"
+  , testCase "False ==> 410 Gone" $
+      after' m5 resource req @?= error' gone410
+  ]
+
+m7Tests :: TestTree
+m7Tests = decisionTestGroup "m7" "Server allows POST to missing resource?"
+  [ testCase "True ==> n11" $
+      let r = resource { allowMissingPost = return True }
+      in after' m7 r req @?= Decision' "n11"
+  , testCase "False ==> 404 Not Found" $
+      after' m7 resource req @?= error' notFound404
+  ]
+
+m16Tests :: TestTree
+m16Tests = decisionTestGroup "m16" "DELETE?"
+  [ testCase "True ==> m20" $
+      let rq = req { reqMethod = methodDelete }
+      in after' m16 resource rq @?= Decision' "m20"
+  , testCase "False ==> n16" $
+      after' m16 resource req @?= Decision' "n16"
+  ]
+
+m20Tests :: TestTree
+m20Tests = decisionTestGroup "m20" "Delete enacted?"
+  [ testCase "True + completed ==> n11" $
+      let r = resource { deleteResource = return True }
+      in after' m20 r req @?= Decision' "n11"
+  , testCase "True + not completed ==> 202 Accepted" $
+      let r = resource { deleteResource = return True, deleteCompleted = return False }
+      in after' m20 r req @?= Done' accepted202
+  , testCase "False ==> 500 Internal Server Error" $
+      after' m20 resource req @?= error' internalServerError500
+  ]
+
+n5Tests :: TestTree
+n5Tests = decisionTestGroup "n5" "Server allows POST to missing resource? (resource did not exist previously)"
+  [ testCase "True ==> n11" $
+      let r = resource { allowMissingPost = return True }
+      in after' n5 r req @?= Decision' "n11"
+  , testCase "False ==> 410 Gone" $
+      after' n5 resource req @?= error' gone410
+  ]
+
+n11Tests :: TestTree
+n11Tests = decisionTestGroup "n11" "Redirect?"
+  [ testCase "True + created + content type accepted ==> 303 See Other" $
+      let
+        r = resource
+          { postAction = return . PostCreateRedir $ ["webcrank"]
+          , contentTypesAccepted = return [("text" // "html", return ())]
+          }
+        rq = req { reqHeaders = HashMap.singleton hContentType ["text/html"] }
+        aft = case after n11 r rq of
+          (d, rd) -> (d, HashMap.lookup hLocation $_reqDataRespHeaders rd, _reqDataDispPath rd)
+      in aft @?= (Done' seeOther303, Just ["http://example.com/webcrank"], ["webcrank"])
+   , testCase "True + created + no content type accepted ==> 415 Unsupported Media Type" $
+      let
+        r = resource
+          { postAction = return . PostCreateRedir $ ["webcrank"]
+          , contentTypesAccepted = return [("text" // "html", return ())]
+          }
+        rq = req { reqHeaders = HashMap.singleton hContentType ["text/plain"] }
+      in after' n11 r rq @?= error' unsupportedMediaType415
+  , testCase "True + process ==> 303 See Other" $
+      let r = resource { postAction = return $ PostProcessRedir $ return "http://example.com/webcrank" }
+      in afterHdrs n11 r req @?=
+        (Done' seeOther303, HashMap.singleton hLocation ["http://example.com/webcrank"])
+  , testCase "False + created + content type accepted ==> p11" $
+      let
+        r = resource
+          { postAction = return . PostCreate $ ["webcrank"]
+          , contentTypesAccepted = return [("text" // "html", return ())]
+          }
+        rq = req { reqHeaders = HashMap.singleton hContentType ["text/html"] }
+      in afterHdrs n11 r rq @?=
+        (Decision' "p11", HashMap.singleton hLocation ["http://example.com/webcrank"])
+  , testCase "False + created + no content type accepted ==> p11" $
+      let
+        r = resource
+          { postAction = return . PostCreate $ ["webcrank"]
+          , contentTypesAccepted = return [("text" // "html", return ())]
+          }
+        rq = req { reqHeaders = HashMap.singleton hContentType ["text/plain"] }
+      in afterHdrs n11 r rq @?=
+        (error' unsupportedMediaType415, HashMap.singleton hLocation ["http://example.com/webcrank"])
+  , testCase "False + process ==> p11" $
+      let r = resource { postAction = return $ PostProcess $ return () }
+      in after' n11 r req @?= Decision' "p11"
+  ]
+
+n16Tests :: TestTree
+n16Tests = decisionTestGroup "n16" "POST? (resource exists)"
+  [ testCase "True ==> n11" $
+      let rq = req { reqMethod = methodPost }
+      in after' n16 resource rq @?= Decision' "n11"
+  , testCase "False ==> o16" $
+      after' n16 resource req @?= Decision' "o16"
+  ]
+
+o14Tests :: TestTree
+o14Tests = decisionTestGroup "o14" "Conflict? (resource exists)"
+  [ testCase "True ==> 409 Conflict" $
+      let r = resource { isConflict = return True }
+      in after' o14 r req @?= error' conflict409
+  , testCase "False + no acceptable content type ==> 415 Unsupported Media Type" $
+      after' o14 resource req @?= error' unsupportedMediaType415
+  , testCase "False + acceptable content type ==> p11" $
+      let
+        r = resource { contentTypesAccepted = return [("text" // "html", return ())] }
+        rq = req { reqHeaders = HashMap.singleton hContentType ["text/html"] }
+      in after' o14 r rq @?= Decision' "p11"
+  ]
+
+o16Tests :: TestTree
+o16Tests = decisionTestGroup "o16" "PUT? (resource exists)"
+  [ testCase "True ==> o14" $
+      let rq = req { reqMethod = methodPut }
+      in after' o16 resource rq @?= Decision' "o14"
+  , testCase "False ==> o18" $
+      after' o16 resource req @?= Decision' "o18"
+  ]
+
+o18Tests :: TestTree
+o18Tests = decisionTestGroup "o18" "Multiple representations?"
+  [ testCase "True ==> 300 Multiple Choices" $
+      let r = resource { multipleChoices = return True }
+      in after' o18 r req @?= Done' multipleChoices300
+  , testCase "False ==> 200 Ok" $
+      after' o18 resource req @?= Done' ok200
+  ]
+
+o20Tests :: TestTree
+o20Tests = decisionTestGroup "o20" "Response includes an entity?"
+  [ testCase "True ==> o18" $
+      let
+        body rd = rd { _reqDataRespBody = Just "webcrank" }
+        aft = fst $ afterWith o20 resource req body
+      in aft @?= Decision' "o18"
+  , testCase "False ==> 204 No Content" $
+      after' o20 resource req @?= Done' noContent204
+  ]
+
+p3Tests :: TestTree
+p3Tests = decisionTestGroup "p3" "Conflict? (resource doesn't exist)"
+  [ testCase "True ==> 409 Conflict" $
+      let r = resource { isConflict = return True }
+      in after' p3 r req @?= error' conflict409
+  , testCase "False + no acceptable content types ==> 415 Unsupported Media Type" $
+      after' p3 resource req @?= error' unsupportedMediaType415
+  , testCase "False + acceptable content type ==> p11" $
+      let
+        r = resource { contentTypesAccepted = return [("text" // "html", return ())] }
+        rq = req { reqHeaders = HashMap.singleton hContentType ["text/html"] }
+      in after' p3 r rq @?= Decision' "p11"
+  ]
+
+p11Tests :: TestTree
+p11Tests = decisionTestGroup "p11" "New resource?"
+  [ testCase "True ==> 201 Created" $
+      let
+        loc rd = rd { _reqDataRespHeaders = HashMap.singleton hLocation ["http://example.com/abc"] }
+        aft = fst $ afterWith p11 resource req loc
+      in aft @?= Done' created201
+  , testCase "False ==> o20" $
+      after' p11 resource req @?= Decision' "o20"
+  ]
+
+decisionTestGroup :: TestName -> TestName -> [TestTree] -> TestTree
+decisionTestGroup s n = testGroup (s <> ". " <> n)
+
+date :: HTTPDate
+date = defaultHTTPDate
+  { hdYear = 1994
+  , hdMonth = 11
+  , hdDay = 15
+  , hdHour = 8
+  , hdMinute = 12
+  , hdSecond = 31
+  , hdWkday = 2
+  }
+
+dateStr :: ByteString
+dateStr = formatHTTPDate date
+
+after
+  :: FlowChart (HaltT TestCrank) Status
+  -> Resource TestCrank
+  -> Req
+  -> (Decision', ReqData)
+after s r rq = afterWith s r rq id
+
+afterWith
+  :: FlowChart (HaltT TestCrank) Status
+  -> Resource TestCrank
+  -> Req
+  -> (ReqData -> ReqData)
+  -> (Decision', ReqData)
+afterWith s r rq f = run where
+  run = case runReader (runCatchT $ step s r f) rq of
+    Left e -> error $ show e
+    Right a -> a
+
+step
+  :: FlowChart (HaltT TestCrank) Status
+  -> Resource TestCrank
+  -> (ReqData -> ReqData)
+  -> TestState (Decision', ReqData)
+step s r f = do
+  let rd = f newReqData
+  case s of
+    Decision _ a -> runTestCrank (runHaltT a) r rd >>= \case
+      (Left (Halt sc), rd', _) -> return (Done' sc, rd')
+      (Left (Error sc e), rd', _) -> return (Error' sc e, rd')
+      (Right (Decision l _), rd', _) -> return (Decision' l, rd')
+      (Right (Done a'), rd', _) -> runTestCrank (runHaltT a') r rd' <&> \case
+        (Left (Halt sc), rd'', _) -> (Done' sc, rd'')
+        (Left (Error sc e), rd'', _) -> (Error' sc e, rd'')
+        (Right sc, rd'', _) -> (Done' sc, rd'')
+    Done _ -> error "can't look past end state of flow chart"
+
+after'
+  :: FlowChart (HaltT TestCrank) Status
+  -> Resource TestCrank
+  -> Req
+  -> Decision'
+after' s r = fst . after s r
+
+afterHdrs
+  :: FlowChart (HaltT TestCrank) Status
+  -> Resource TestCrank
+  -> Req
+  -> (Decision', HeadersMap)
+afterHdrs s r rq = case after s r rq of
+  (d, rd) -> (d, _reqDataRespHeaders rd)
+
+afterCharset
+  :: FlowChart (HaltT TestCrank) Status
+  -> Resource TestCrank
+  -> Req
+  -> (Decision', Maybe Charset)
+afterCharset s r rq = case after s r rq of
+  (d, rd) -> (d, _reqDataRespCharset rd)
+
+data Decision'
+  = Error' Status LB.ByteString
+  | Done' Status
+  | Decision' String
+  deriving Eq
+
+error' :: Status -> Decision'
+error' s = Error' s (LB.fromStrict $ statusMessage s)
+
+instance Show Decision' where
+  show = \case
+    Error' sc e -> mconcat
+      [ show $ statusCode sc
+      , " "
+      , B.unpack $ statusMessage sc
+      , " - "
+      , show . B.unpack . LB.toStrict $ e
+      ]
+    Done' sc -> mconcat [show $ statusCode sc, " ", B.unpack $ statusMessage sc ]
+    Decision' l -> l
diff --git a/test/HandleRequestTests.hs b/test/HandleRequestTests.hs
new file mode 100644
--- /dev/null
+++ b/test/HandleRequestTests.hs
@@ -0,0 +1,96 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module HandleRequestTests where
+
+import qualified Data.HashMap.Strict as HashMap
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import Webcrank
+import TestServerAPI
+
+handleRequestTests :: TestTree
+handleRequestTests = testGroup "handleRequest"
+  [ getTest
+  , postTest
+  , putTest
+  , deleteTest
+  , notModifiedTest
+  , serviceUnavailableTest
+  ]
+
+getTest :: TestTree
+getTest = testCase "GET" $
+  let r = resource { contentTypesProvided = return [("plain" // "text", return "webcrank")] }
+  in handleTestReq r req @?=
+    Res ok200
+      (HashMap.singleton hContentType ["plain/text"])
+      (Just "webcrank")
+
+postTest :: TestTree
+postTest = testCase "POST" $
+  let
+    r = resource
+      { contentTypesAccepted =
+          return [("plain" // "text", putResponseHeader hContentType "plain/text" >> writeLBS "webcrank")]
+      , postAction = return . PostCreate $ ["new"]
+      , allowedMethods = return [methodGet, methodHead, methodPost]
+      }
+    rq = req
+      { reqMethod = methodPost
+      , reqHeaders = HashMap.singleton hContentType ["plain/text"]
+      }
+  in handleTestReq r rq @?=
+    Res created201
+      (HashMap.fromList [(hLocation, ["http://example.com/new"]), (hContentType, ["plain/text"])])
+      (Just "webcrank")
+
+putTest :: TestTree
+putTest = testCase "PUT" $
+  let
+    r = resource
+      { contentTypesAccepted =
+          return [("plain" // "text", putResponseHeader hContentType "plain/text" >> writeLBS "webcrank")]
+      , allowedMethods = return [methodGet, methodHead, methodPut]
+      }
+    rq = req
+      { reqMethod = methodPut
+      , reqHeaders = HashMap.singleton hContentType ["plain/text"]
+      }
+  in handleTestReq r rq @?=
+    Res ok200
+      (HashMap.singleton hContentType ["plain/text"])
+      (Just "webcrank")
+
+deleteTest :: TestTree
+deleteTest = testCase "DELETE" $
+  let
+    r = resource
+      { allowedMethods = return [methodGet, methodHead, methodDelete]
+      , deleteResource = return True
+      }
+    rq = req { reqMethod = methodDelete }
+  in handleTestReq r rq @?=
+    Res noContent204
+      (HashMap.singleton hContentType ["application/octet-stream"])
+      Nothing
+
+notModifiedTest :: TestTree
+notModifiedTest = testCase "Not modified" $
+  let
+    r = resource
+      { lastModified = return defaultHTTPDate
+      , generateETag = return $ StrongETag "webcrank"
+      , expires = return defaultHTTPDate
+      }
+    rq = req { reqHeaders = HashMap.singleton hIfModifiedSince [ formatHTTPDate defaultHTTPDate ] }
+  in handleTestReq r rq @?=
+    Res notModified304
+      (HashMap.fromList [(hETag, ["webcrank"]), (hExpires, [formatHTTPDate defaultHTTPDate])])
+      Nothing
+
+serviceUnavailableTest :: TestTree
+serviceUnavailableTest = testCase "Service unavailable" $
+  let r = resource { serviceAvailable = return False }
+  in resStatus (handleTestReq r req) @?= serviceUnavailable503
+
diff --git a/test/ParserTests.hs b/test/ParserTests.hs
new file mode 100644
--- /dev/null
+++ b/test/ParserTests.hs
@@ -0,0 +1,47 @@
+{-# LANGUAGE OverlappingInstances #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module ParserTests where
+
+import Control.Applicative
+import Data.Attoparsec.ByteString.Char8
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Char8 as B
+import Data.CaseInsensitive (CI)
+import qualified Data.CaseInsensitive as CI
+import Test.Tasty
+import Test.Tasty.QuickCheck
+import Test.Tasty.HUnit
+
+import Webcrank.Internal
+
+instance Arbitrary ByteString where
+  arbitrary = B.pack <$> arbitrary
+
+instance (Arbitrary s, CI.FoldCase s) => Arbitrary (CI s) where
+  arbitrary = CI.mk <$> arbitrary
+
+parserTests :: TestTree
+parserTests = testGroup "parser tests"
+  [ csl1Tests
+  ]
+
+testParse :: (Eq a, Show a) => Parser a -> ByteString -> Maybe a -> TestTree
+testParse p s e = testCase (show s) (either (const Nothing) Just (parseOnly p s) @?= e)
+
+csl1Tests :: TestTree
+csl1Tests = testGroup "csl1"
+  [ testCsl1 "foo,bar" (Just ["foo", "bar"])
+  , testCsl1 "foo ,bar," (Just ["foo", "bar"])
+  , testCsl1 "foo, ,bar,charlie  " (Just ["foo", "bar", "charlie"])
+  , testCsl1 ", foo, ,bar" (Just ["foo", "bar"])
+  , testCsl1 "" Nothing
+  , testCsl1 "," Nothing
+  , testCsl1 ",  ," Nothing
+  ]
+
+testCsl1 :: ByteString -> Maybe [ByteString] -> TestTree
+testCsl1 = testParse (csl1 tokenP)
+
diff --git a/test/ResourceTests.hs b/test/ResourceTests.hs
new file mode 100644
--- /dev/null
+++ b/test/ResourceTests.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module ResourceTests where
+
+-- import Network.HTTP.Types
+-- import Test.Tasty
+-- import Test.Tasty.HUnit
+
+-- import Webcrank
+
+-- runResourceTests = testGroup "runResource"
+--   [ testCase "service is unavailable" testServiceUnavailable
+--   , testCase "not implemented" testNotImplemented
+--   , testCase "uri too long" testUriTooLong
+--   , testCase "method not allowed" testMethodNotAllowed
+--   , testCase "bad request" testBadRequest
+--   , testCase "unauthorized" testUnauthorized
+--   , testCase "forbidden" testForbidden
+--   , testCase "unknown or unsupported Content-* headers" testInvalidContentHeaders
+--   , testCase "unknown Content-Type" testUnknownContentType
+--   , testCase "entity too large" testEntityTooLarge
+--   , testCase "OPTIONS" testOptions
+--   , testCase "no acceptable media type" testNoAcceptableMediaType
+--   , testCase "no acceptable charset" testNoAcceptableCharset
+--   ]
+
+-- (<@=?>) = (=<<) . (@=?)
+
+-- testServiceUnavailable = resp503 <@=?> runResource rs testRq where
+--   rs = testResource { serviceAvailable = value False }
+--   resp503 = testResp serviceUnavailable503
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>503 Service Unavailable</title></head><body><h1>Service Unavailable</h1>The server is currently unable to handle the request due to a temporary overloading or maintenance of the server.<p><hr><address>webcrank web server</address></body></html>")
+
+-- testNotImplemented = resp501 <@=?> runResource testResource rq where
+--   rq = testRq { testRqMethod = "MOVE" }
+--   resp501 = testResp notImplemented501
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>501 Not Implemented</title></head><body><h1>Not Implemented</h1>The server does not support the MOVE method.<p><hr><address>webcrank web server</address></body></html>")
+
+-- testUriTooLong = resp414 <@=?> runResource rs testRq where
+--   rs = testResource { uriTooLong = value True }
+--   resp414 = testResp requestURITooLong414
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>414 Request-URI Too Long</title></head><body><h1>Request-URI Too Long</h1>Request-URI Too Long<p><hr><address>webcrank web server</address></body></html>")
+
+-- testMethodNotAllowed = resp405 <@=?> runResource testResource rq where
+--   rq = testRq { testRqMethod = "POST" }
+--   resp405 = testResp methodNotAllowed405
+--                      [(hContentType, "text/html"), ("Allow", "GET, HEAD")]
+--                      (testBody "<html><head><title>405 Method Not Allowed</title></head><body><h1>Method Not Allowed</h1>Method Not Allowed<p><hr><address>webcrank web server</address></body></html>")
+
+-- testBadRequest = resp400 <@=?> runResource rs testRq where
+--   rs = testResource { malformedRequest = value True }
+--   resp400 = testResp badRequest400
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>400 Bad Request</title></head><body><h1>Bad Request</h1>Bad Request<p><hr><address>webcrank web server</address></body></html>")
+
+-- testUnauthorized = resp401 <@=?> runResource rs testRq where
+--   realm = "Basic realm=\"Webcrank\""
+--   rs = testResource { isAuthorized = unauthorized realm }
+--   resp401 = testResp unauthorized401
+--                      [(hContentType, "text/html"), ("WWW-Authenticate", realm)]
+--                      (testBody "<html><head><title>401 Unauthorized</title></head><body><h1>Unauthorized</h1>Unauthorized<p><hr><address>webcrank web server</address></body></html>")
+
+-- testForbidden = resp403 <@=?> runResource rs testRq where
+--   rs = testResource { forbidden = value True }
+--   resp403 = testResp forbidden403
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>403 Forbidden</title></head><body><h1>Forbidden</h1>Forbidden<p><hr><address>webcrank web server</address></body></html>")
+
+-- testInvalidContentHeaders = resp501 <@=?> runResource rs testRq where
+--   rs = testResource { validContentHeaders = value False }
+--   resp501 = testResp notImplemented501
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>501 Not Implemented</title></head><body><h1>Not Implemented</h1>The server does not support the GET method.<p><hr><address>webcrank web server</address></body></html>")
+
+-- testUnknownContentType = resp415 <@=?> runResource rs testRq where
+--   rs = testResource { knownContentType = value False }
+--   resp415 = testResp unsupportedMediaType415
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>415 Unsupported Media Type</title></head><body><h1>Unsupported Media Type</h1>Unsupported Media Type<p><hr><address>webcrank web server</address></body></html>")
+
+-- testEntityTooLarge = resp413 <@=?> runResource rs testRq where
+--   rs = testResource { validEntityLength = value False }
+--   resp413 = testResp requestEntityTooLarge413
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>413 Request Entity Too Large</title></head><body><h1>Request Entity Too Large</h1>Request Entity Too Large<p><hr><address>webcrank web server</address></body></html>")
+
+-- testOptions = resp200 <@=?> runResource rs rq where
+--   hdrs = [("X-Test", "1, 2, 3")]
+--   rs = testResource { allowedMethods = return [methodGet, methodOptions], options = return hdrs }
+--   rq = testRq { testRqMethod = methodOptions }
+--   resp200 = testResp ok200 hdrs Nothing
+
+-- testNoAcceptableMediaType = resp406 <@=?> runResource testResource rq where
+--   rq = testRq { testRqHeaders = [(hAccept, "text/plain")] }
+--   resp406 = testResp notAcceptable406
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>406 Not Acceptable</title></head><body><h1>Not Acceptable</h1>No acceptable media type available<p><hr><address>webcrank web server</address></body></html>")
+
+-- testNoAcceptableCharset = resp406 <@=?> runResource testResource rq where
+--   rq = testRq { testRqHeaders = [("Accept-Charset", "utf-16")] }
+--   resp406 = testResp notAcceptable406
+--                      [(hContentType, "text/html")]
+--                      (testBody "<html><head><title>406 Not Acceptable</title></head><body><h1>Not Acceptable</h1>No acceptable charset available<p><hr><address>webcrank web server</address></body></html>")
+
diff --git a/test/TestServerAPI.hs b/test/TestServerAPI.hs
new file mode 100644
--- /dev/null
+++ b/test/TestServerAPI.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module TestServerAPI where
+
+import Control.Applicative
+import Control.Monad.Catch.Pure
+import Control.Monad.Reader
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Lazy as LB
+import qualified Data.HashMap.Strict as HashMap
+import Data.Maybe
+import Network.HTTP.Date
+import Network.HTTP.Types
+
+import Webcrank
+import Webcrank.ServerAPI
+import Webcrank.ServerAPI.WebcrankT
+
+data Req = Req
+  { reqMethod :: Method
+  , reqURI :: ByteString
+  , reqHeaders :: HeadersMap
+  , reqTime :: HTTPDate
+  } deriving Show
+
+data Res = Res
+  { resStatus :: Status
+  , resHeaders :: HeadersMap
+  , resBody :: Maybe LB.ByteString
+  } deriving (Show, Eq)
+
+req :: Req
+req = Req
+  { reqMethod = methodGet
+  , reqURI = "http://example.com"
+  , reqHeaders = HashMap.empty
+  , reqTime = defaultHTTPDate
+      { hdYear = 1994
+      , hdMonth = 11
+      , hdDay = 15
+      , hdHour = 8
+      , hdMinute = 12
+      , hdSecond = 31
+      , hdWkday = 2
+      }
+  }
+
+res :: Res
+res = Res ok200 HashMap.empty Nothing
+
+type TestState = CatchT (Reader Req)
+type TestCrank = WebcrankT TestState
+
+runTestCrank :: TestCrank a -> Resource TestCrank -> ReqData -> TestState (a, ReqData, LogData)
+runTestCrank a r = runWebcrankT a testAPI r
+
+testAPI :: ServerAPI TestCrank
+testAPI = ServerAPI
+  { srvGetRequestMethod = lift $ asks reqMethod
+  , srvGetRequestURI = lift $ asks reqURI
+  , srvGetRequestHeader = \h -> lift $ asks ((listToMaybe =<<) . HashMap.lookup h . reqHeaders)
+  , srvGetRequestTime = lift $ asks reqTime
+  }
+
+handleTestReq :: Resource TestCrank -> Req -> Res
+handleTestReq r rq = runReader run rq where
+  run = handleE <$> run'
+  handleE = \case
+    Left e -> error $ show e
+    Right (s, hs, b) -> Res s hs b
+  run' = runCatchT (handleRequest (\a -> runTestCrank a r newReqData))
+
diff --git a/webcrank.cabal b/webcrank.cabal
--- a/webcrank.cabal
+++ b/webcrank.cabal
@@ -1,5 +1,5 @@
 name:               webcrank
-version:            0.2
+version:            0.2.0.1
 license:            BSD3
 license-file:       LICENSE
 author:             Mark Hibberd <mark@hibberd.id.au>
@@ -14,6 +14,14 @@
 description:
   Webmachine inspired toolkit for building http applications and services.
 
+extra-source-files:
+  .ghci
+  .gitignore
+  .travis.yml
+  HLint.hs
+  LICENSE
+  README.md
+
 source-repository   head
   type:             git
   location:         https://github.com/webcrank/webcrank.git
@@ -63,6 +71,12 @@
   type:             exitcode-stdio-1.0
 
   main-is:          tests.hs
+
+  other-modules:    DecisionTests
+                  , HandleRequestTests
+                  , ParserTests
+                  , ResourceTests
+                  , TestServerAPI
 
   hs-source-dirs:   test
 
