module ImportExportSpec where import Protolude ( Either (Left, Right), Maybe (..), Text, fromMaybe, show, ($), (&), (<&>), (<>), ) import Protolude qualified as P import Data.Aeson (Value (Object), decode, eitherDecode, eitherDecodeStrictText) import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Lazy qualified as BSL import Data.Hourglass (timePrint, toFormat) import Data.Text (unpack) import Data.Text qualified as T import Data.ULID (ULID) import Database.SQLite.Simple (query_) import Test.Hspec ( Spec, describe, it, shouldBe, shouldNotBe, shouldStartWith, ) import Config (defaultConfig) import FullTask (FullTask, emptyFullTask) import FullTask qualified import ImportExport (getNdjsonLines, insertImportTask) import ImportTask (ImportTask (ImportTask, notes, tags, task)) import Note (Note (Note)) import Task (Task (body, modified_utc, ulid, user), emptyTask) import TaskToNote (TaskToNote (TaskToNote)) import TaskToNote qualified import TestUtils (withMemoryDb) import Utils (emptyUlid, parseUtc, setDateTime, ulidText2utc, zeroTime) spec :: Spec spec = do let conf = defaultConfig describe "Import" $ do it "parses any sensible datetime string" $ do -- TODO: Maybe keep microseconds and nanoseconds -- , ("YYYY-MM-DDTH:MI:S.msusZ", "2024-03-15T22:20:05.637913Z") -- , ("YYYY-MM-DDTH:MI:S.msusnsZ", "2024-03-15T22:20:05.637913438Z") let dateMap :: [(Text, Text)] = [ ("YYYY-MM-DD", "2024-03-15") , ("YYYY-MM-DD H:MI", "2024-03-15 22:20") , ("YYYY-MM-DDTH:MIZ", "2024-03-15T22:20Z") , ("YYYY-MM-DD H:MI:S", "2024-03-15 22:20:05") , ("YYYY-MM-DDTH:MI:SZ", "2024-03-15T22:20:05Z") , ("YYYYMMDDTHMIS", "20240315T222005") , ("YYYY-MM-DDTH:MI:S.msZ", "2024-03-15T22:20:05.637Z") , ("YYYY-MM-DDTH:MI:S.msZ", "2024-03-15T22:20:05.637123Z") , ("YYYY-MM-DDTH:MI:S.msZ", "2024-03-15T22:20:05.637123456Z") ] P.forM_ dateMap $ \(formatTxt, utcTxt) -> do case parseUtc utcTxt of Nothing -> P.die "Invalid UTC string" Just utcStamp -> let timeFmt = formatTxt & T.unpack & toFormat in (utcStamp & timePrint timeFmt) `shouldBe` T.unpack ( utcTxt & T.replace "123" "" & T.replace "456" "" ) let utcTxt = "2024-03-15T22:20:05.386777444Z" printFmt = "YYYY-MM-DDTH:MI:S.ms" & T.unpack & toFormat -- Truncates microseconds and nanoseconds expected = "2024-03-15T22:20:05.386" (utcTxt & parseUtc <&> timePrint printFmt) `shouldBe` Just expected it "imports a JSON task and puts unused fields into metadata" $ do withMemoryDb conf $ \memConn -> do let jsonTask = "\ \ { 'body': 'Just a test' \ \ , 'utc': '2024-03-15T10:32:51.853Z' \ \ , 'tags': ['one', 'two'] \ \ , 'notes': ['first note', 'second note'] \ \ , 'xxx': 'yyy' \ \ } \ \" & T.replace "'" "\"" case eitherDecodeStrictText jsonTask of Left error -> P.die $ "Error decoding JSON: " <> show error Right importTaskRecord -> do _ <- insertImportTask conf memConn importTaskRecord tasks :: [FullTask] <- query_ memConn "SELECT * FROM tasks_view" case tasks of [insertedTask] -> do insertedTask.body `shouldBe` "Just a test" insertedTask.tags `shouldBe` Just ["one", "two"] insertedTask.metadata `shouldBe` Just (Object $ KeyMap.fromList [("xxx", "yyy")]) _ -> P.die "More than one task found" taskToNotes :: [TaskToNote] <- query_ memConn "SELECT * FROM task_to_note" taskToNotes `shouldBe` [ TaskToNote { ulid = "01hs0tqwwd0006vtdjjey5vdae" , task_ulid = "01hs0tqwwd0004yy9et9d4wksy" , note = "first note" } , TaskToNote { ulid = "01hs0tqwwd0003pynthn2xpd5s" , task_ulid = "01hs0tqwwd0004yy9et9d4wksy" , note = "second note" } ] it "imports a JSON task with notes" $ do withMemoryDb conf $ \memConn -> do let jsonTask = "{\"body\":\"Just a test\", \"notes\":[\"A note\"]}" case eitherDecode jsonTask of Left error -> P.die $ "Error decoding JSON: " <> show error Right importTaskRecord -> do result <- insertImportTask conf memConn importTaskRecord unpack (show result) `shouldStartWith` "📥 Imported task \"Just a test\" with ulid " taskToNotes :: [TaskToNote] <- query_ memConn "SELECT * FROM task_to_note" case taskToNotes of [taskToNote] -> do taskToNote.ulid `shouldNotBe` "" taskToNote.task_ulid `shouldNotBe` "" taskToNote.note `shouldBe` "A note" _ -> P.die "More than one task_to_note row found" tasks :: [FullTask] <- query_ memConn "SELECT * FROM tasks_view" case tasks of [updatedTask] -> do updatedTask.ulid `shouldNotBe` "" updatedTask.modified_utc `shouldNotBe` "" updatedTask.user `shouldNotBe` "" updatedTask { FullTask.ulid = "" , FullTask.modified_utc = "" , FullTask.user = "" } `shouldBe` emptyFullTask { FullTask.body = "Just a test" , -- TODO: Fix after notes are returned as a JSON array FullTask.notes = Just [] , FullTask.priority = Just 1.0 , FullTask.metadata = case decode jsonTask of Just (Object keyMap) -> keyMap & KeyMap.delete "body" & KeyMap.delete "notes" & \kMap -> if KeyMap.null kMap then Nothing else Just $ Object kMap _ -> Nothing } _ -> P.die "More than one task found" it "imports a JSON task with an ISO8601 created_at field" $ do withMemoryDb conf $ \memConn -> do let utc = "2024-03-15T10:32:51.386777444Z" -- ULID only has millisecond precision: utcFromUlid = "2024-03-15 10:32:51.387" jsonTask = "{\"body\":\"Just a test\",\"created_at\":\"{{utc}}\"}" & T.replace "{{utc}}" utc case eitherDecodeStrictText jsonTask of Left error -> P.die $ "Error decoding JSON: " <> show error Right importTaskRecord -> do _ <- insertImportTask conf memConn importTaskRecord tasks :: [FullTask] <- query_ memConn "SELECT * FROM tasks_view" case tasks of [insertedTask] -> ulidText2utc insertedTask.ulid `shouldBe` Just utcFromUlid _ -> P.die "More than one task found" it "imports JSON task with notes and sets the created_utc for notes" $ do withMemoryDb conf $ \memConn -> do let utc = "2024-03-15 10:32:51" jsonTask = "{\"body\":\"Just a test\",\ \\"created_at\":\"{{utc}}\",\ \\"notes\": [\"Just a note\"]}" & T.replace "{{utc}}" utc expectedTaskToNote = TaskToNote { ulid = emptyUlid & P.flip setDateTime (utc & parseUtc & fromMaybe zeroTime) & show @ULID & T.toLower , task_ulid = "01hs0tqw1r0007h0mj78s1jntz" , note = "Just a note" } case eitherDecodeStrictText jsonTask of Left error -> P.die $ "Error decoding JSON: " <> show error Right importTaskRecord -> do _ <- insertImportTask conf memConn importTaskRecord taskToNoteList :: [TaskToNote] <- query_ memConn "SELECT * FROM task_to_note" case taskToNoteList of [taskToNote] -> do taskToNote.ulid `shouldNotBe` expectedTaskToNote.ulid (taskToNote.ulid & T.take 10) `shouldBe` (expectedTaskToNote.ulid & T.take 10) _ -> P.die "Found more than one note" it "imports a GitHub issue" $ do gitHubIssue <- BSL.readFile "test/fixtures/github-issue.json" withMemoryDb conf $ \memConn -> do let expectedImpTask = ImportTask { task = emptyTask { Task.ulid = "01hrz2qz7g0001qzskfchf4dek" , Task.body = "Support getting the note body from stdin" , Task.user = "ad-si" , Task.modified_utc = "2024-03-14 18:14:14.000" } , notes = [] , tags = [] } case eitherDecode gitHubIssue of Left error -> P.die $ "Error decoding JSON: " <> show error Right importTaskRecord -> do _ <- insertImportTask conf memConn importTaskRecord taskList :: [Task] <- query_ memConn "SELECT * FROM tasks" case taskList of [task] -> do task.ulid `shouldBe` expectedImpTask.task.ulid task.body `shouldBe` expectedImpTask.task.body task.user `shouldBe` expectedImpTask.task.user task.modified_utc `shouldBe` expectedImpTask.task.modified_utc _ -> P.die "Found more than one note" describe "Export" $ do it "exports several tasks as NDJSON including notes" $ do let task = emptyTask { Task.ulid = "01jczsz9c328fm7xydcwxbmv6n" , Task.body = "Buy milk" , Task.user = "ad-si" , Task.modified_utc = "2024-09-24 22:31:09.123" } importTask = ImportTask { task = task , notes = [Note "01jczszra25ec48pagzcw5qw6j" "Test note"] , tags = [] } taskJson = "[{\ \\"awake_utc\":null,\ \\"body\":\"Buy milk\",\ \\"closed_utc\":null,\ \\"due_utc\":null,\ \\"group_ulid\":null,\ \\"metadata\":null,\ \\"modified_utc\":\"2024-09-24 22:31:09.123\",\ \\"notes\":[{\ \\"body\":\"Test note\",\ \\"ulid\":\"01jczsz9c35ec48pagzcw5qw6j\"\ \}],\ \\"priority\":1,\ \\"ready_utc\":null,\ \\"recurrence_duration\":null,\ \\"repetition_duration\":null,\ \\"review_utc\":null,\ \\"state\":null,\ \\"tags\":null,\ \\"ulid\":\"01jczsz9c328fm7xydcwxbmv6n\",\ \\"user\":\"ad-si\",\ \\"waiting_utc\":null\ \}]" res <- withMemoryDb conf $ \memConn -> do _ <- insertImportTask conf memConn importTask getNdjsonLines memConn (show res :: P.Text) `shouldBe` taskJson