todoist-sdk-0.1.2.1: integration-test/TaskIntegrationSpec.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
module TaskIntegrationSpec (spec) where
import Helpers
( assertSucceeds
, buildTestTask
, generateUniqueName
, getTestConfig
, liftTodoist
)
import qualified Web.Todoist.Domain.Project as P
import Web.Todoist.Domain.Task
( NewTask (..)
, Task (..)
, TodoistTaskM (..)
, moveTaskBuilder
, updateTaskBuilder
, completedTasksQueryParamBuilder
, filterTaskBuilder
)
import qualified Web.Todoist.Domain.Task as T
import Web.Todoist.Domain.Types (Content (..), Description (..), ProjectId (..), TaskId (..))
import Web.Todoist.Internal.Config (TodoistConfig)
import Web.Todoist.Internal.Error (TodoistError)
import Web.Todoist.Runner (todoist)
import Web.Todoist.Util.Builder
( runBuilder
, withContent
, withDescription
, withDueDate
, withPriority
, withProjectId
)
import Control.Applicative (pure)
import Control.Exception (bracket)
import Control.Monad (forM_, mapM, void)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT)
import Data.Bool (Bool (..))
import Data.Either (Either (..))
import Data.Function (($))
import Data.Functor ((<$>))
import Data.Int (Int)
import qualified Data.List as L
import Data.Maybe (Maybe (..))
import Data.Semigroup ((<>))
import Data.Text (Text, pack)
import System.IO (IO, putStrLn)
import Test.Hspec (Spec, describe, it, pendingWith, runIO, shouldBe, shouldSatisfy)
import Text.Show (show)
import GHC.Base (mempty)
spec :: Spec
spec = do
maybeConfig <- runIO getTestConfig
case maybeConfig of
Nothing ->
it "requires TODOIST_TEST_API_TOKEN" $
pendingWith "TODOIST_TEST_API_TOKEN not set"
Just config -> do
taskLifecycleSpec config
taskCompletionSpec config
getTasksSpec config
updateTaskSpec config
taskFilterSpec config
moveTaskSpec config
taskLifecycleSpec :: TodoistConfig -> Spec
taskLifecycleSpec config = describe "Task lifecycle (create, get, delete)" $ do
it "creates, retrieves, and deletes a task with all fields verified" $ do
-- Generate unique names
projectName <- pack <$> generateUniqueName "IntegTest-TaskLifecycle-Project"
taskContent <- pack <$> generateUniqueName "IntegTest-TaskLifecycle-Task"
-- Use withTestTask for automatic cleanup
withTestTask config projectName taskContent $ \projectId taskId -> do
-- Verify we can retrieve the task
task <- liftTodoist config (getTask taskId)
-- Extract task fields for verification
let Task
{ _id = retrievedId
, _content = retrievedContent
, _description = retrievedDescription
, _project_id = retrievedProjectId
} = task
-- Verify task ID matches
liftIO $ retrievedId `shouldBe` taskId
-- Verify content matches
liftIO $ retrievedContent `shouldBe` Content taskContent
-- Verify description was set
liftIO $ retrievedDescription `shouldBe` Description "Test task description for integration testing"
-- Verify project ID matches
liftIO $ retrievedProjectId `shouldBe` projectId
-- Test explicit delete (cleanup will handle if this fails)
liftTodoist config (deleteTask taskId)
taskCompletionSpec :: TodoistConfig -> Spec
taskCompletionSpec config = describe "Task completion/uncompletion lifecycle" $ do
it "marks a task as complete then reopens it" $ do
-- Generate unique names
projectName <- pack <$> generateUniqueName "IntegTest-TaskCompletion-Project"
taskContent <- pack <$> generateUniqueName "IntegTest-TaskCompletion-Task"
withTestTask config projectName taskContent $ \_ taskId -> do
-- Verify task starts as not completed
task1 <- liftTodoist config (getTask taskId)
let Task {_completed_at = initialCompletedAt} = task1
liftIO $ initialCompletedAt `shouldBe` Nothing
-- Close the task
liftTodoist config (closeTask taskId)
-- Try to get the task after closing
getResult <- liftIO $ todoist config (getTask taskId)
case getResult of
Right closedTask -> do
-- If we can still get it, verify it's marked as completed
let Task {_completed_at = completedAt} = closedTask
liftIO $ completedAt `shouldSatisfy` (\case Just _ -> True; Nothing -> False)
Left _ -> do
-- If we can't get it, that's also acceptable (API behavior)
liftIO $ putStrLn "Task not retrievable after closing (expected API behavior)"
-- Unclose the task
liftTodoist config (uncloseTask taskId)
-- Verify task is now uncompleted
task2 <- liftTodoist config (getTask taskId)
let Task {_completed_at = finalCompletedAt} = task2
liftIO $ finalCompletedAt `shouldBe` Nothing
getTasksSpec :: TodoistConfig -> Spec
getTasksSpec config = describe "Get multiple tasks" $ do
it "creates 3 tasks, retrieves them via getTasks, validates count and properties" $ do
-- Generate unique names
projectName <- pack <$> generateUniqueName "IntegTest-GetTasks-Project"
baseName <- generateUniqueName "IntegTest-GetTasks-Task"
let taskContent1 = pack $ baseName <> "-Task1"
let taskContent2 = pack $ baseName <> "-Task2"
let taskContent3 = pack $ baseName <> "-Task3"
let taskContents = [taskContent1, taskContent2, taskContent3]
withTestTasks config projectName taskContents $ \projectId taskIds -> do
-- Get all tasks for this project
let ProjectId {getProjectId = projIdText} = projectId
let taskParam = runBuilder T.taskParamBuilder (withProjectId projIdText)
tasks <- liftTodoist config (getTasks taskParam)
-- Verify we got exactly 3 tasks
let taskCount = L.length tasks
liftIO $ taskCount `shouldBe` (3 :: Int)
-- Extract task IDs and contents from results
let taskIdsResult = L.map (\(Task {_id = tid}) -> tid) tasks
let taskContentsResult = L.map (\(Task {_content = content}) -> content) tasks
-- Verify all 3 task IDs are present
let [expectedId1, expectedId2, expectedId3] = taskIds
liftIO $ (expectedId1 `L.elem` taskIdsResult) `shouldBe` True
liftIO $ (expectedId2 `L.elem` taskIdsResult) `shouldBe` True
liftIO $ (expectedId3 `L.elem` taskIdsResult) `shouldBe` True
-- Verify all 3 task contents are present
liftIO $ (Content taskContent1 `L.elem` taskContentsResult) `shouldBe` True
liftIO $ (Content taskContent2 `L.elem` taskContentsResult) `shouldBe` True
liftIO $ (Content taskContent3 `L.elem` taskContentsResult) `shouldBe` True
-- Verify each task has the correct project_id
forM_ tasks $ \(Task {_project_id = taskProjId}) -> do
liftIO $ taskProjId `shouldBe` projectId
updateTaskSpec :: TodoistConfig -> Spec
updateTaskSpec config = describe "Update task" $ do
it "creates a task, updates its properties, verifies changes" $ do
-- Generate unique names
projectName <- pack <$> generateUniqueName "IntegTest-UpdateTask-Project"
originalContent <- pack <$> generateUniqueName "IntegTest-UpdateTask-Original"
withTestTask config projectName originalContent $ \projectId taskId -> do
-- Verify initial state
task1 <- liftTodoist config (getTask taskId)
let Task
{ _content = initialContent
, _description = initialDescription
, _priority = initialPriority
} = task1
liftIO $ initialContent `shouldBe` Content originalContent
liftIO $ initialDescription `shouldBe` Description "Test task description for integration testing"
liftIO $ initialPriority `shouldBe` 1 -- default priority
-- Update the task
let updatedContent = originalContent <> "-Updated"
let updatedDescription = "Updated task description"
let updatedPriority = 3
let taskPatch =
runBuilder
updateTaskBuilder
( withContent updatedContent
<> withDescription updatedDescription
<> withPriority updatedPriority
)
updatedNewTask <- liftTodoist config (updateTask taskPatch taskId)
-- Verify the response contains updated values
let NewTask
{ _content = responseContent
, _description = responseDescription
, _priority = responsePriority
} = updatedNewTask
liftIO $ responseContent `shouldBe` Content updatedContent
liftIO $ responseDescription `shouldBe` Description updatedDescription
liftIO $ responsePriority `shouldBe` updatedPriority
-- Fetch the task again to verify persistence
task2 <- liftTodoist config (getTask taskId)
let Task
{ _content = persistedContent
, _description = persistedDescription
, _priority = persistedPriority
, _project_id = persistedProjectId
} = task2
liftIO $ persistedContent `shouldBe` Content updatedContent
liftIO $ persistedDescription `shouldBe` Description updatedDescription
liftIO $ persistedPriority `shouldBe` updatedPriority
-- Verify project_id unchanged
liftIO $ persistedProjectId `shouldBe` projectId
it "supports partial updates (only updating specific fields)" $ do
-- Generate unique names
projectName <- pack <$> generateUniqueName "IntegTest-PartialUpdate-Project"
taskContent <- pack <$> generateUniqueName "IntegTest-PartialUpdate-Task"
withTestTask config projectName taskContent $ \_ taskId -> do
-- Get initial state
task1 <- liftTodoist config (getTask taskId)
let Task
{ _content = originalContent
, _description = originalDescription
} = task1
-- Partial update: only change priority
let taskPatch =
runBuilder
updateTaskBuilder
(withPriority 4)
_ <- liftTodoist config (updateTask taskPatch taskId)
-- Verify only priority changed
task2 <- liftTodoist config (getTask taskId)
let Task
{ _content = finalContent
, _description = finalDescription
, _priority = finalPriority
} = task2
liftIO $ finalPriority `shouldBe` 4
-- Other fields should remain unchanged
liftIO $ finalContent `shouldBe` originalContent
liftIO $ finalDescription `shouldBe` originalDescription
taskFilterSpec :: TodoistConfig -> Spec
taskFilterSpec config = describe "Task filtering" $ do
it "searches tasks using text filter" $ do
-- Generate unique names with distinctive search term
projectName <- pack <$> generateUniqueName "IntegTest-Filter-Project"
searchTerm <- generateUniqueName "UNIQUE_SEARCHABLE_TERM"
let taskContent = pack $ "Task with " <> searchTerm
withTestTask config projectName taskContent $ \_ taskId -> do
-- Search for tasks containing our unique term
-- Use search: prefix for text search in Todoist filter syntax
let filter = runBuilder (filterTaskBuilder (pack $ "search: " <> searchTerm)) mempty
taskIds <- liftTodoist config (getTasksByFilter filter)
-- Verify our task is in the results
let TaskId {getTaskId = expectedIdText} = taskId
let taskIdTexts = L.map (\(TaskId {getTaskId = tid}) -> tid) taskIds
liftIO $ (expectedIdText `L.elem` taskIdTexts) `shouldBe` True
it "retrieves completed tasks by due date range" $ do
-- Generate unique names
projectName <- pack <$> generateUniqueName "IntegTest-CompletedDue-Project"
taskContent <- pack <$> generateUniqueName "IntegTest-CompletedDue-Task"
withTestTask config projectName taskContent $ \projectId taskId -> do
-- Set a due date on the task first
let taskPatch =
runBuilder
updateTaskBuilder
(withDueDate "2025-11-03")
_ <- liftTodoist config (updateTask taskPatch taskId)
-- Complete the task
liftTodoist config (closeTask taskId)
-- Query for completed tasks by due date
-- Use a wide range to ensure we catch our task
let ProjectId {getProjectId = projIdText} = projectId
let queryParamWithProject =
runBuilder
(completedTasksQueryParamBuilder "2025-11-01" "2025-11-30")
(withProjectId projIdText)
completedTaskIds <- liftTodoist config (getCompletedTasksByDueDate queryParamWithProject)
-- Verify our task is in the results
let TaskId {getTaskId = expectedIdText} = taskId
let taskIdTexts = L.map (\(TaskId {getTaskId = tid}) -> tid) completedTaskIds
liftIO $ (expectedIdText `L.elem` taskIdTexts) `shouldBe` True
-- Unclose for cleanup
liftTodoist config (uncloseTask taskId)
it "retrieves completed tasks by completion date range" $ do
-- Generate unique names
projectName <- pack <$> generateUniqueName "IntegTest-CompletedDate-Project"
taskContent <- pack <$> generateUniqueName "IntegTest-CompletedDate-Task"
withTestTask config projectName taskContent $ \projectId taskId -> do
-- Complete the task
liftTodoist config (closeTask taskId)
-- Query for completed tasks by completion date (today)
-- Use a wide range to ensure we catch our task
let ProjectId {getProjectId = projIdText} = projectId
let queryParamWithProject =
runBuilder
(completedTasksQueryParamBuilder "2025-11-01" "2025-11-30")
(withProjectId projIdText)
completedTaskIds <- liftTodoist config (getCompletedTasksByCompletionDate queryParamWithProject)
-- Verify our task is in the results
let TaskId {getTaskId = expectedIdText} = taskId
let taskIdTexts = L.map (\(TaskId {getTaskId = tid}) -> tid) completedTaskIds
liftIO $ (expectedIdText `L.elem` taskIdTexts) `shouldBe` True
-- Unclose for cleanup
liftTodoist config (uncloseTask taskId)
moveTaskSpec :: TodoistConfig -> Spec
moveTaskSpec config = describe "Move task between projects" $ do
it "creates two projects, moves a task from one to the other" $ do
-- Generate unique names
project1Name <- pack <$> generateUniqueName "IntegTest-MoveTask-Project1"
project2Name <- pack <$> generateUniqueName "IntegTest-MoveTask-Project2"
taskContent <- pack <$> generateUniqueName "IntegTest-MoveTask-Task"
-- Create project 1 with task
withTestTask config project1Name taskContent $ \project1Id taskId -> do
-- Create project 2
project2Id <- liftTodoist config (P.addProject $ runBuilder (P.createProjectBuilder project2Name) mempty)
-- Verify task is in project 1
task1 <- liftTodoist config (getTask taskId)
let Task {_project_id = originalProjectId} = task1
liftIO $ originalProjectId `shouldBe` project1Id
-- Move task to project 2
let ProjectId {getProjectId = project2IdText} = project2Id
let moveTaskData = runBuilder moveTaskBuilder (withProjectId project2IdText)
movedTaskId <- liftTodoist config (moveTask moveTaskData taskId)
-- Verify returned task ID matches
let TaskId {getTaskId = expectedIdText} = taskId
let TaskId {getTaskId = movedIdText} = movedTaskId
liftIO $ movedIdText `shouldBe` expectedIdText
-- Verify task is now in project 2
task2 <- liftTodoist config (getTask taskId)
let Task {_project_id = createProjectBuilderId} = task2
liftIO $ createProjectBuilderId `shouldBe` project2Id
-- Clean up project 2 (task will be deleted by withTestTask cleanup)
liftTodoist config (P.deleteProject project2Id)
{- | Create a test project and task, run an action with the task ID, then clean up
Uses bracket to ensure cleanup happens even if the action fails
Tasks are deleted before the project
-}
withTestTask ::
TodoistConfig ->
Text -> -- project name
Text -> -- task content
(ProjectId -> TaskId -> ExceptT TodoistError IO a) ->
IO ()
withTestTask config projectName taskContent action = do
let createResources = do
liftIO $ putStrLn $ "Creating test project: " <> show projectName
projectId <- liftTodoist config (P.addProject $ runBuilder (P.createProjectBuilder projectName) mempty)
liftIO $ putStrLn $ "Creating test task: " <> show taskContent
let ProjectId {getProjectId = projIdText} = projectId
let taskCreate = buildTestTask taskContent projIdText
newTaskResult <- liftTodoist config (createTask taskCreate)
let NewTask {_id = newTaskIdText} = newTaskResult
let taskId = newTaskIdText
pure (projectId, taskId)
let deleteResources (projectId, taskId) = do
liftIO $ putStrLn $ "Cleaning up test task: " <> show taskContent
void $ todoist config (deleteTask taskId)
liftIO $ putStrLn $ "Cleaning up test project: " <> show projectName
void $ todoist config (P.deleteProject projectId)
let runAction (projectId, taskId) = void $ assertSucceeds $ action projectId taskId
bracket (assertSucceeds createResources) deleteResources runAction
{- | Create a test project and multiple tasks, run an action with their IDs, then clean up
Ensures all tasks are deleted before the project is deleted
-}
withTestTasks ::
TodoistConfig ->
Text -> -- project name
[Text] -> -- task contents
(ProjectId -> [TaskId] -> ExceptT TodoistError IO a) ->
IO ()
withTestTasks config projectName taskContents action = do
let createResources = do
liftIO $ putStrLn $ "Creating test project: " <> show projectName
projectId <- liftTodoist config (P.addProject $ runBuilder (P.createProjectBuilder projectName) mempty)
liftIO $ putStrLn $ "Creating " <> show (L.length taskContents) <> " test tasks"
let ProjectId {getProjectId = projIdText} = projectId
taskIds <-
mapM
( \content -> do
let taskCreate = buildTestTask content projIdText
newTaskResult <- liftTodoist config (createTask taskCreate)
let NewTask {_id = newTaskIdText} = newTaskResult
pure newTaskIdText
)
taskContents
pure (projectId, taskIds)
let deleteResources (projectId, taskIds) = do
liftIO $ putStrLn $ "Cleaning up " <> show (L.length taskIds) <> " test tasks"
forM_ taskIds $ \taskId ->
void $ todoist config (deleteTask taskId)
liftIO $ putStrLn $ "Cleaning up test project: " <> show projectName
void $ todoist config (P.deleteProject projectId)
let runAction (projectId, taskIds) = void $ assertSucceeds $ action projectId taskIds
bracket (assertSucceeds createResources) deleteResources runAction