packages feed

freckle-app-1.9.1.0: tests/Freckle/App/CsvSpec.hs

module Freckle.App.CsvSpec
  ( spec
  ) where

import Freckle.App.Prelude

import Conduit (ConduitT, MonadThrow, ResourceT, yieldMany)
import Control.Monad.Validate (Validate, refute, runValidate)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.Csv hiding (header)
import qualified Data.List.NonEmpty as NE
import Data.Sequence.NonEmpty (NESeq)
import qualified Data.Vector as V
import Data.Void (Void)
import Freckle.App.Csv
import Test.Hspec

data Foo = Foo
  { someInt :: Int
  , someText :: Text
  }
  deriving stock (Eq, Show, Generic)

data FooError = BadFoo
  deriving stock (Eq, Show)

instance FromNamedRecord Foo where
  parseNamedRecord = genericParseNamedRecord defaultOptions

instance ToNamedRecord Foo where
  toNamedRecord = genericToNamedRecord defaultOptions

instance DefaultOrdered Foo where
  headerOrder = genericHeaderOrder defaultOptions

instance FromRecord Foo where
  parseRecord = genericParseRecord defaultOptions

instance ToRecord Foo where
  toRecord = genericToRecord defaultOptions

instance ValidateHeader Foo where
  validateHeader = defaultValidateOrderedHeader

spec :: Spec
spec = do
  describe "defaultOptions" $ do
    let
      contentRows = "123,this is some text\r\n456,this is more text\r\n"
      content = "someInt,someText\r\n" <> contentRows
      header = ["someInt", "someText"]
      rows :: [Foo]
      rows = [Foo 123 "this is some text", Foo 456 "this is more text"]
    it "should parse records as expected" $
      decode HasHeader content
        `shouldBe` Right (V.fromList rows)

    it "should parse named records as expected" $
      decodeByName content
        `shouldBe` Right (V.fromList header, V.fromList rows)

    it "should serialize records as expected" $
      encode rows
        `shouldBe` contentRows

    it "should serialize named records as expected" $
      encodeDefaultOrderedByName rows
        `shouldBe` content

  for_ [CustomParser, FromNamedRecordParser] $ \testType -> do
    describe (csvSinkTestName testType) $ do
      it "should generate errors for missing columns" $ do
        let source = yieldMany ["someInt,som_text\n", "3,meep\n", "4,morp\n"]
        result <- csvSinkTest testType source pure
        runValidate result
          `shouldBe` Left (pure $ CsvMissingColumn @Void "someText")

      it "should generate errors for parse errors" $ do
        result <- csvSinkTest testType (yieldMany []) pure
        runValidate result
          `shouldBe` Left
            (pure $ CsvParseException @Void 1 "parse error (not enough input)")

      it "should generate errors per row" $ do
        let
          source =
            yieldMany
              [ "someInt,someText\n"
              , "a,meep\n"
              , "1,flip\n"
              , "b,morp\n"
              , "c,merp\n"
              ]
          errorLine = \case
            CsvParseException i _ -> i
            _ -> -1
        result <- csvSinkTest testType source pure
        first (fmap errorLine) (runValidate result)
          `shouldBe` Left (NE.fromList [2, 4, 5])

      it "should generate errors for non-utf8-encoded files" $ do
        let source = yieldMany [BS.pack [0XFF, 0XFF, 0XFF, 0XFF]]
        result <- csvSinkTest testType source pure
        runValidate result `shouldBe` Left (pure $ CsvUnknownFileEncoding @Void)

      it "should generate errors using user-specified validation" $ do
        let
          validate foo@Foo {..}
            | someInt < 4 = pure foo
            | otherwise = refute $ pure $ CsvExceptionExtension BadFoo
          source =
            yieldMany
              [ "someInt,someText\n"
              , "3,meep\n"
              , "4,morp\n"
              , "1,whomp\n"
              , "5,merp\n"
              ]

        result <- csvSinkTest testType source $ traverse validate
        runValidate result
          `shouldBe` Left
            ( NE.fromList
                [ CsvExceptionExtension BadFoo
                , CsvExceptionExtension BadFoo
                ]
            )

      it "should successfully parse a CSV" $ do
        let
          validate foo@Foo {..}
            | someInt < 4 = pure foo
            | otherwise = refute $ pure $ CsvExceptionExtension BadFoo
          source =
            yieldMany
              ["someInt,someText\n", "1,meep\n", "2,morp\n", "3,merp\n"]

        result <- csvSinkTest testType source $ traverse validate
        runValidate result
          `shouldBe` Right
            (V.fromList [Foo 1 "meep", Foo 2 "morp", Foo 3 "merp"])

      it "should successfully parse a CSV with weird whitespace" $ do
        let
          validate foo@Foo {..}
            | someInt < 4 = pure foo
            | otherwise = refute $ pure $ CsvExceptionExtension BadFoo
          source =
            yieldMany
              [ "someInt\t   ,someText\n"
              , "1, \t meep\n"
              , "2,morp\n"
              , "3 ,  merp\n"
              ]

        result <- csvSinkTest testType source $ traverse validate
        runValidate result
          `shouldBe` Right
            (V.fromList [Foo 1 "meep", Foo 2 "morp", Foo 3 "merp"])

data CSVSinkTest = CustomParser | FromNamedRecordParser

csvSinkTestName :: CSVSinkTest -> String
csvSinkTestName = \case
  CustomParser -> "csvWithParserAndValidationSink"
  FromNamedRecordParser -> "csvWithValidationSink"

customFooHeaderValidator :: Header -> Validate (NESeq String) ()
customFooHeaderValidator h = hasHeader h "someInt" *> hasHeader h "someText"

customFooP :: NamedRecord -> Parser Foo
customFooP r = Foo <$> r .: "someInt" <*> r .: "someText"

csvSinkTest
  :: forall err m
   . (MonadThrow m, MonadUnliftIO m, PrimMonad m)
  => CSVSinkTest
  -> ConduitT () ByteString (ResourceT m) ()
  -> ( Vector Foo
       -> Validate (NonEmpty (CsvException err)) (Vector Foo)
     )
  -> m (Validate (NonEmpty (CsvException err)) (Vector Foo))
csvSinkTest = \case
  CustomParser ->
    csvWithParserAndValidationSink customFooHeaderValidator customFooP
  FromNamedRecordParser -> csvWithValidationSink