hledger-flow-0.18.0: test/CSVImport/Integration.hs
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
module CSVImport.Integration (tests) where
import Control.Concurrent.STM
import qualified Data.Map.Strict as Map
import qualified Data.Text.IO as T
import Hledger.Flow.Import.ImportHelpersTurtle (extraIncludesForFile, includePreamble, toIncludeFiles, writeIncludesFromInventory, writeToplevelAllYearsInclude)
import Hledger.Flow.Import.Inventory (findImportFiles)
import Hledger.Flow.RuntimeOptions (RuntimeOptions)
import Hledger.Flow.Types (LogMessage)
import Hledger.Flow.PathHelpers
import qualified Path
import qualified Path.IO as PathIO
import Data.Either (isLeft)
import System.IO.Error (tryIOError)
import System.FilePath ((</>))
import qualified System.FilePath as FilePath
import Test.HUnit
import TestHelpers (defaultOpts, withTestDir, touchAll)
import Prelude hiding (readFile, writeFile)
-- Refresh the inventory exactly as the CLI does before writing includes.
writeIncludes :: RuntimeOptions -> TChan LogMessage -> AbsDir -> [AbsFile] -> IO [AbsFile]
writeIncludes opts ch stopAt journals = do
inventory <- findImportFiles stopAt
writeIncludesFromInventory opts ch stopAt inventory journals
testExtraIncludesForFile :: Test
testExtraIncludesForFile =
TestCase $ withTestDir $ \tmpdirAbsPath -> do
let tmpdir = Path.toFilePath tmpdirAbsPath
let accountDir = "import/john/bogartbank/savings"
let opening = tmpdir </> accountDir </> "2017-opening.journal"
let closing = tmpdir </> accountDir </> "2017-closing.journal"
let accountInclude = tmpdir </> accountDir </> "2017-include.journal"
let expectedEmpty = [(accountInclude, [])]
ch <- newTChanIO
extraOpening1 <- extraIncludesForFile (defaultOpts tmpdirAbsPath) ch accountInclude ["opening.journal"] [] []
assertEqual "The opening journal should not be included when it is not on disk" expectedEmpty extraOpening1
extraClosing1 <- extraIncludesForFile (defaultOpts tmpdirAbsPath) ch accountInclude ["closing.journal"] [] []
assertEqual "The closing journal should not be included when it is not on disk" expectedEmpty extraClosing1
touchAll [opening, closing]
extraOpening2 <- extraIncludesForFile (defaultOpts tmpdirAbsPath) ch accountInclude ["opening.journal"] [] []
assertEqual "The opening journal should be included when it is on disk" [(accountInclude, [FilePath.normalise opening])] (Map.map (map FilePath.normalise) extraOpening2)
extraClosing2 <- extraIncludesForFile (defaultOpts tmpdirAbsPath) ch accountInclude ["closing.journal"] [] []
assertEqual "The closing journal should be included when it is on disk" [(accountInclude, [FilePath.normalise closing])] (Map.map (map FilePath.normalise) extraClosing2)
testExtraIncludesPrices :: Test
testExtraIncludesPrices =
TestCase $ withTestDir $ \tmpdirAbsPath -> do
let tmpdir = Path.toFilePath tmpdirAbsPath
let priceFile = "prices" </> "2020" </> "prices.journal"
let includeFile = tmpdir </> "import" </> "2020-include.journal"
let expectedEmpty = [(includeFile, [])]
Path.parseAbsDir (tmpdir </> "import") >>= PathIO.createDir
ch <- newTChanIO
price1 <- extraIncludesForFile (defaultOpts tmpdirAbsPath) ch includeFile [] [] ["prices.journal"]
assertEqual "The price file should not be included when it is not on disk" expectedEmpty price1
touchAll [tmpdir </> priceFile]
let expectedPricePath = tmpdir </> "import" </> ".." </> priceFile
price2 <- extraIncludesForFile (defaultOpts tmpdirAbsPath) ch includeFile [] [] ["prices.journal"]
assertEqual "The price file should be included when it is on disk" [(includeFile, [FilePath.normalise expectedPricePath])] (Map.map (map FilePath.normalise) price2)
testIncludesPrePost :: Test
testIncludesPrePost =
TestCase $ withTestDir $ \tmpdirAbsPath -> do
let tmpdir = Path.toFilePath tmpdirAbsPath
let ownerDir = tmpdir </> "import" </> "john"
let includeFile = ownerDir </> "2019-include.journal"
let pre = ownerDir </> "_manual_" </> "2019" </> "pre-import.journal"
let post = ownerDir </> "_manual_" </> "2019" </> "post-import.journal"
touchAll [pre, post]
let includeMap =
Map.singleton
includeFile
[ ownerDir </> "bank1" </> "2019-include.journal",
ownerDir </> "bank2" </> "2019-include.journal"
]
ch <- newTChanIO
fileMap <- toIncludeFiles (defaultOpts tmpdirAbsPath) ch includeMap
let expectedText =
includePreamble
<> "\n"
<> "include _manual_/2019/pre-import.journal\n"
<> "include bank1/2019-include.journal\n"
<> "include bank2/2019-include.journal\n"
<> "include _manual_/2019/post-import.journal\n"
let expectedMap = Map.singleton includeFile expectedText
assertEqual "All pre/post files on disk should be included" expectedMap fileMap
testIncludesOpeningClosing :: Test
testIncludesOpeningClosing =
TestCase $ withTestDir $ \tmpdirAbsPath -> do
let tmpdir = Path.toFilePath tmpdirAbsPath
let ownerDir = tmpdir </> "import/john"
let accountDir = ownerDir </> "bank1" </> "savings"
let includeFile = accountDir </> "2019-include.journal"
let opening = accountDir </> "2019-opening.journal"
let closing = accountDir </> "2019-closing.journal"
touchAll [opening, closing]
let includeMap = Map.singleton includeFile [accountDir </> "3-journal" </> "2019" </> "2019-01-30.journal"]
ch <- newTChanIO
fileMap <- toIncludeFiles (defaultOpts tmpdirAbsPath) ch includeMap
let expectedText =
includePreamble
<> "\n"
<> "include 2019-opening.journal\n"
<> "include 3-journal/2019/2019-01-30.journal\n"
<> "include 2019-closing.journal\n"
let expectedMap = Map.singleton includeFile expectedText
assertEqual "All opening/closing files on disk should be included" expectedMap fileMap
testIncludesPrices :: Test
testIncludesPrices =
TestCase $ withTestDir $ \tmpdirAbsPath -> do
let tmpdir = Path.toFilePath tmpdirAbsPath
let importDir = tmpdir </> "import"
let includeFile = importDir </> "2020-include.journal"
let prices = tmpdir </> "prices" </> "2020" </> "prices.journal"
let pre = importDir </> "_manual_" </> "2020" </> "pre-import.journal"
let post = importDir </> "_manual_" </> "2020" </> "post-import.journal"
touchAll [prices, pre, post]
let includeMap = Map.singleton includeFile [importDir </> "john" </> "2020-include.journal"]
ch <- newTChanIO
fileMap <- toIncludeFiles (defaultOpts tmpdirAbsPath) ch includeMap
let expectedText =
includePreamble
<> "\n"
<> "include _manual_/2020/pre-import.journal\n"
<> "include john/2020-include.journal\n"
<> "include ../prices/2020/prices.journal\n"
<> "include _manual_/2020/post-import.journal\n"
let expectedMap = Map.singleton includeFile expectedText
assertEqual "The price file should be included together with any pre/post files" expectedMap fileMap
testWriteToplevelAllYearsInclude :: Test
testWriteToplevelAllYearsInclude =
TestCase $ withTestDir $ \tmpdirAbsPath -> do
let tmpdir = Path.toFilePath tmpdirAbsPath
written1 <- writeToplevelAllYearsInclude (defaultOpts tmpdirAbsPath)
let expectedFile = tmpdir </> "all-years.journal"
assertEqual "Should write the top-level all-years include file" [expectedFile] written1
let expectedNoDirectives =
includePreamble
<> "\n"
<> "include import/all-years.journal\n"
actualNoDirectives <- T.readFile expectedFile
assertEqual "Top-level include should not reference directives when missing" expectedNoDirectives actualNoDirectives
let directives = tmpdir </> "directives.journal"
touchAll [directives]
written2 <- writeToplevelAllYearsInclude (defaultOpts tmpdirAbsPath)
assertEqual "Should overwrite the top-level all-years include file" [expectedFile] written2
let expectedWithDirectives =
includePreamble
<> "\n"
<> "include directives.journal\n"
<> "include import/all-years.journal\n"
actualWithDirectives <- T.readFile expectedFile
assertEqual "Top-level include should reference directives when present" expectedWithDirectives actualWithDirectives
testWriteIncludes :: Test
testWriteIncludes =
TestCase $ withTestDir $ \tmpdirAbsPath -> do
let tmpdir = Path.toFilePath tmpdirAbsPath
let journal = tmpdir </> "import/john/mybank/checking/3-journal/2019/2019-01-01.journal"
touchAll [journal]
ch <- newTChanIO
stopAt <- Path.parseAbsDir (tmpdir </> "import")
input <- Path.parseAbsFile journal
expectedFinal <- Path.parseAbsFile (tmpdir </> "import/2019-include.journal")
narrower <- Path.parseAbsDir (tmpdir </> "import" </> "other")
rejected <- tryIOError (writeIncludesFromInventory (defaultOpts tmpdirAbsPath) ch narrower [] [input])
assertBool "Reject a journal outside the boundary before writing" (isLeft rejected)
PathIO.doesFileExist expectedFinal >>= assertBool "No ancestor include written on rejection" . not
written <- writeIncludes (defaultOpts tmpdirAbsPath) ch stopAt [input]
assertEqual "writeIncludes should stop at the requested directory" [expectedFinal] written
finalContents <- T.readFile (Path.toFilePath expectedFinal)
let expectedContents =
includePreamble
<> "\n"
<> "include john/2019-include.journal\n"
assertEqual "Top-level year include should reference the owner include" expectedContents finalContents
_ <- writeIncludes (defaultOpts tmpdirAbsPath) ch stopAt []
emptyIndex <- T.readFile (tmpdir </> "import" </> "all-years.journal")
assertEqual "An empty import has a readable index" (includePreamble <> "\n") emptyIndex
testDiscoveredManualIncludes :: Test
testDiscoveredManualIncludes = TestCase $ withTestDir $ \base -> do
let root = Path.toFilePath base </> "import"
owner = root </> "john"
account = owner </> "bank" </> "checking"
journal = account </> "3-journal/2020/march.journal"
pre = owner </> "_manual_/2020/pre-import.journal"
post = owner </> "_manual_/2020/post-import.journal"
unrelated = owner </> "2021-include.journal"
touchAll [journal, pre, post, unrelated,
owner </> "_manual_/2022/notes.journal",
owner </> "_manual_/20x3/post-import.journal",
account </> ".trash/2024/_manual_/2024/post-import.journal"]
T.writeFile unrelated "; handwritten include, leave alone\n"
stopAt <- Path.parseAbsDir root
input <- Path.parseAbsFile journal
ch <- newTChanIO
_ <- writeIncludes (defaultOpts base) ch stopAt [input]
contents <- T.readFile (owner </> "2020-include.journal")
assertEqual "Discovered manual files surround generated children exactly once"
(includePreamble <> "\ninclude _manual_/2020/pre-import.journal\ninclude bank/2020-include.journal\ninclude _manual_/2020/post-import.journal\n") contents
index <- T.readFile (root </> "all-years.journal")
assertEqual "Only supported manual paths establish years"
(includePreamble <> "\ninclude 2020-include.journal\n") index
mapM_ (\file -> Path.parseAbsFile file >>= PathIO.removeFile) ([journal, pre, post] :: [FilePath])
_ <- writeIncludes (defaultOpts base) ch stopAt []
mapM_ (\file -> do
actual <- T.readFile file
assertEqual "Retired year and account ledgers become empty" (includePreamble <> "\n") actual)
([owner </> "2020-include.journal", account </> "all-years.journal", root </> "2020-include.journal"] :: [FilePath])
T.readFile unrelated >>= assertEqual "Do not clear handwritten indexes" "; handwritten include, leave alone\n"
tests :: Test
tests =
TestList
[ testExtraIncludesForFile,
testExtraIncludesPrices,
testIncludesPrePost,
testIncludesOpeningClosing,
testIncludesPrices,
testWriteToplevelAllYearsInclude,
testWriteIncludes,
testDiscoveredManualIncludes
]