todoist-sdk-0.1.2.1: integration-test/ProjectIntegrationSpec.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module ProjectIntegrationSpec (spec) where
import Helpers (assertSucceeds, buildTestProject, generateUniqueName, getTestConfig, liftTodoist)
import Web.Todoist.Domain.Project
( ProjectCreate
, TodoistProjectM (..)
, createProjectBuilder
, updateProjectBuilder
)
import qualified Web.Todoist.Domain.Project as P
import Web.Todoist.Domain.Types
( Description (..)
, IsFavorite (..)
, Name (..)
, ProjectId (..)
, getName
)
import Web.Todoist.Internal.Config (TodoistConfig)
import Web.Todoist.Internal.Error (TodoistError)
import Web.Todoist.Internal.Types
( Action (..)
, ProjectPermissions (..)
, RoleActions (..)
)
import Web.Todoist.Lens ((^.))
import Web.Todoist.Runner (todoist)
import Web.Todoist.Util.Builder (runBuilder, withDescription, withName, withIsFavorite)
import Control.Exception (bracket)
import Control.Monad (forM_, mapM, mapM_, void)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT)
import Data.Bool (Bool (..))
import Data.Either (Either (..))
import Data.Eq ((==))
import Data.Function (const, ($), (.))
import Data.Functor ((<$>))
import Data.Int (Int)
import qualified Data.List as L
import Data.Maybe (Maybe (..))
import Data.Semigroup ((<>))
import Data.Text (Text, isInfixOf, pack)
import qualified Data.Text as T
import System.IO (IO, putStrLn)
import Test.Hspec (Spec, describe, it, pendingWith, runIO, shouldBe, shouldSatisfy)
import Text.Show (show)
import GHC.Base (mempty, undefined)
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
projectLifecycleSpec config
archiveUnarchiveSpec config
getAllProjectsSpec config
getProjectCollaboratorsSpec config
getProjectPermissionsSpec config
updateProjectSpec config
projectLifecycleSpec :: TodoistConfig -> Spec
projectLifecycleSpec config = describe "Project lifecycle (create, get, delete)" $ do
it "creates, retrieves, and deletes a project with all fields set" $ do
-- Generate unique project name
createProjectBuilderName <- pack <$> generateUniqueName "IntegTest-ProjectLifecycle"
-- Build a complete project with all fields using the Builder pattern
let testProject = buildTestProject createProjectBuilderName
-- Extract expected values from testProject for verification
let expectedName = testProject ^. P.projectCreateName
expectedDescription = testProject ^. P.projectCreateDescription
expectedViewStyle = testProject ^. P.projectCreateViewStyle
expectedIsFavorite = testProject ^. P.projectCreateIsFavorite
-- Use withTestProjectCreate to handle creation and cleanup
withTestProjectCreate config testProject $ \projectId -> do
-- Get the project by ID and verify all fields
project <- liftTodoist config (getProject projectId)
let projId = project ^. P.projectId
projName = project ^. P.name
projDescription = project ^. P.description
projViewStyle = project ^. P.viewStyle
projIsFavorite = project ^. P.isFavorite
-- Verify the project details match expected values
liftIO $ projId `shouldBe` projectId
liftIO $ projName `shouldBe` expectedName
-- Verify description field matches testProject
liftIO $ Just projDescription `shouldBe` expectedDescription
-- Verify view_style field matches testProject
liftIO $ Just projViewStyle `shouldBe` expectedViewStyle
-- Verify is_favorite field matches testProject
liftIO $ projIsFavorite `shouldBe` expectedIsFavorite
-- Test deletion within the action
liftTodoist config (deleteProject projectId)
-- Verify project no longer exists (should get an error)
verifyResult <- liftIO $ todoist config (getProject projectId)
liftIO $ verifyResult `shouldSatisfy` (\case Left _ -> True; _ -> False)
archiveUnarchiveSpec :: TodoistConfig -> Spec
archiveUnarchiveSpec config = describe "Project archive/unarchive lifecycle" $ do
it "archives and unarchives a project successfully" $ do
-- Generate unique project name
projectName <- pack <$> generateUniqueName "IntegTest-ArchiveUnarchive"
-- Use withTestProject to handle creation and cleanup
withTestProject config projectName $ \projectId -> do
-- Archive the project
archivedId <- liftTodoist config (archiveProject projectId)
-- Verify returned ID matches
let ProjectId {getProjectId = pidId} = projectId
let ProjectId {getProjectId = archivedPidId} = archivedId
liftIO $ archivedPidId `shouldBe` pidId
-- Verify project is archived by getting it
P.Project {_is_archived = isArchived} <- liftTodoist config (getProject projectId)
liftIO $ isArchived `shouldBe` P.IsArchived True
-- Unarchive the project
unarchivedId <- liftTodoist config (unarchiveProject projectId)
-- Verify returned ID matches
let ProjectId {getProjectId = unarchivedPidId} = unarchivedId
liftIO $ unarchivedPidId `shouldBe` pidId
-- Verify project is no longer archived
P.Project {_is_archived = isStillArchived} <- liftTodoist config (getProject projectId)
liftIO $ isStillArchived `shouldBe` P.IsArchived False
getAllProjectsSpec :: TodoistConfig -> Spec
getAllProjectsSpec config = describe "Get all projects" $ do
it
"creates 3 projects, retrieves them via getAllProjects, validates count and properties, then deletes them"
$ do
-- Generate unique base name for this test run
baseName <- generateUniqueName "IntegTest-GetAll"
-- Create 3 projects with unique names
let projectName1 = pack $ baseName <> "-Project1"
let projectName2 = pack $ baseName <> "-Project2"
let projectName3 = pack $ baseName <> "-Project3"
let projectNames = [projectName1, projectName2, projectName3]
-- Use withTestProjects to handle creation and cleanup
withTestProjects config projectNames $ \case
[projectId1, projectId2, projectId3] -> do
-- Get all projects
allProjects <- liftTodoist config getAllProjects
-- Filter to only our test projects
let testPrefix = pack baseName
let ourProjects = L.filter (\(P.Project {_name = n}) -> testPrefix `isInfixOf` getName n) allProjects
-- Verify we got exactly 3 projects
let projectCount = L.length ourProjects
liftIO $ projectCount `shouldBe` (3 :: Int)
-- Extract the project names from our filtered list
let projectNamesResult = L.map (\(P.Project {_name = n}) -> n) ourProjects
-- Verify all 3 project names are present
liftIO $ (Name projectName1 `L.elem` projectNamesResult) `shouldBe` True
liftIO $ (Name projectName2 `L.elem` projectNamesResult) `shouldBe` True
liftIO $ (Name projectName3 `L.elem` projectNamesResult) `shouldBe` True
-- Verify each project has the expected properties
let project1Maybe = L.find (\(P.Project {_name = n}) -> n == Name projectName1) ourProjects
let project2Maybe = L.find (\(P.Project {_name = n}) -> n == Name projectName2) ourProjects
let project3Maybe = L.find (\(P.Project {_name = n}) -> n == Name projectName3) ourProjects
-- Verify all projects were found
case (project1Maybe, project2Maybe, project3Maybe) of
( Just (P.Project {_id = id1, _name = name1})
, Just (P.Project {_id = id2, _name = name2})
, Just (P.Project {_id = id3, _name = name3})
) -> do
-- Verify project IDs match
liftIO $ id1 `shouldBe` projectId1
liftIO $ id2 `shouldBe` projectId2
liftIO $ id3 `shouldBe` projectId3
-- Verify project names match
liftIO $ name1 `shouldBe` Name projectName1
liftIO $ name2 `shouldBe` Name projectName2
liftIO $ name3 `shouldBe` Name projectName3
_ -> do
liftIO $ putStrLn "Failed to find all 3 projects in the filtered list"
liftIO $ False `shouldBe` True
_ -> undefined -- impossible case
getProjectCollaboratorsSpec :: TodoistConfig -> Spec
getProjectCollaboratorsSpec config = describe "Get project collaborators" $ do
it "retrieves collaborators for a project and validates their structure" $ do
-- Generate unique project name
projectName <- pack <$> generateUniqueName "IntegTest-Collaborators"
-- Use withTestProject to handle creation and cleanup
withTestProject config projectName $ \projectId -> do
-- Get project collaborators
collaborators <- liftTodoist config (getProjectCollaborators projectId)
-- Validate structure of each collaborator if any exist
-- Note: Personal projects may have no collaborators, which is valid
let validateCollaborator (P.Collaborator {_id = collabId, _name = collabName, _email = collabEmail}) = do
-- Verify _id is non-empty
T.null collabId `shouldBe` False
-- Verify _name is non-empty
T.null (getName collabName) `shouldBe` False
-- Verify _email is non-empty and contains '@'
T.null collabEmail `shouldBe` False
("@" `T.isInfixOf` collabEmail) `shouldBe` True
-- Validate each collaborator's structure
liftIO $ mapM_ validateCollaborator collaborators
getProjectPermissionsSpec :: TodoistConfig -> Spec
getProjectPermissionsSpec config = describe "Get project permissions" $ do
it "creates 3 projects, retrieves permissions, validates structure, then deletes projects" $ do
-- Generate unique base name for this test run
baseName <- generateUniqueName "IntegTest-Permissions"
-- Create 3 project names
let projectName1 = pack $ baseName <> "-Project1"
let projectName2 = pack $ baseName <> "-Project2"
let projectName3 = pack $ baseName <> "-Project3"
let projectNames = [projectName1, projectName2, projectName3]
-- Use withTestProjects to handle creation and cleanup
withTestProjects config projectNames $ \projectIds -> do
-- Verify we created 3 projects
liftIO $ L.length projectIds `shouldBe` (3 :: Int)
-- Get permissions (static endpoint - doesn't use project IDs)
permissions <- liftTodoist config getProjectPermissions
-- Validate structure exists
liftIO $ do
-- Both arrays should be present (may be empty but must exist)
p_project_collaborator_actions permissions `shouldSatisfy` const True
p_workspace_collaborator_actions permissions `shouldSatisfy` const True
-- If there are role actions, validate their structure
let validateRoleAction (RoleActions {p_name = roleName, p_actions = actions}) = do
-- p_name is type-safe CollaboratorRole - if it parsed, it's valid
-- Don't check for specific role, just that it parsed correctly
roleName `shouldSatisfy` const True
-- Validate each action has a non-empty name
forM_ actions $ \(Action {p_name = actionName}) -> do
T.null actionName `shouldBe` False
-- Validate all roles in both arrays
mapM_ validateRoleAction (p_project_collaborator_actions permissions)
mapM_ validateRoleAction (p_workspace_collaborator_actions permissions)
-- Projects will be automatically deleted by withTestProjects bracket
updateProjectSpec :: TodoistConfig -> Spec
updateProjectSpec config = describe "Update project" $ do
it "creates a project, updates its properties, verifies changes, then deletes it" $ do
-- Generate unique project name
originalName <- pack <$> generateUniqueName "IntegTest-Update-Original"
-- Define description values
let originalDescription = "Original description"
let updatedDescription = "Updated description"
-- Create initial project with specific properties
let initialProject = runBuilder (createProjectBuilder originalName) (withDescription originalDescription)
withTestProjectCreate config initialProject $ \projectId -> do
-- Verify initial state
project1 <- liftTodoist config (getProject projectId)
liftIO $ do
let P.Project {P._name = proj1Name, P._description = proj1Desc, P._is_favorite = proj1Fav} = project1
proj1Name `shouldBe` Name originalName
proj1Desc `shouldBe` Description originalDescription
proj1Fav `shouldBe` IsFavorite False -- default from createProjectBuilder
-- Update the project (change name, description, and favorite status)
let updatedName = originalName <> "-Updated"
let projectUpdate = runBuilder updateProjectBuilder (withName updatedName <> withDescription updatedDescription <> withIsFavorite True)
updatedProject <- liftTodoist config (updateProject projectUpdate projectId)
-- Verify the response contains updated values
liftIO $ do
let P.Project
{ P._name = updatedProjName
, P._description = updatedProjDesc
, P._is_favorite = updatedProjFav
} = updatedProject
updatedProjName `shouldBe` Name updatedName
updatedProjDesc `shouldBe` Description updatedDescription
updatedProjFav `shouldBe` IsFavorite True
-- Fetch the project again to double-check persistence
project2 <- liftTodoist config (getProject projectId)
liftIO $ do
let P.Project
{ P._name = proj2Name
, P._description = proj2Desc
, P._is_favorite = proj2Fav
, P._view_style = proj2ViewStyle
} = project2
let P.Project {P._view_style = proj1ViewStyle} = project1
proj2Name `shouldBe` Name updatedName
proj2Desc `shouldBe` Description updatedDescription
proj2Fav `shouldBe` IsFavorite True
-- Verify unchanged fields remain unchanged
proj2ViewStyle `shouldBe` proj1ViewStyle
it "supports partial updates (only updating specific fields)" $ do
-- Generate unique project name
projectName <- pack <$> generateUniqueName "IntegTest-PartialUpdate"
-- Define initial description
let initialDescription = "Initial description"
-- Create initial project
let initialProject = runBuilder (createProjectBuilder projectName) (withDescription initialDescription)
withTestProjectCreate config initialProject $ \projectId -> do
-- Get initial state
project1 <- liftTodoist config (getProject projectId)
let P.Project {P._description = originalDescription} = project1
-- Partial update: only change is_favorite
let partialUpdate = runBuilder updateProjectBuilder (withIsFavorite True)
updatedProject <- liftTodoist config (updateProject partialUpdate projectId)
-- Verify only is_favorite changed
liftIO $ do
let P.Project {P._is_favorite = updatedFav, P._name = updatedName, P._description = updatedDesc} = updatedProject
updatedFav `shouldBe` IsFavorite True
-- Other fields should remain unchanged
updatedName `shouldBe` Name projectName
updatedDesc `shouldBe` originalDescription
{- | Create a test project from a ProjectCreate, run an action with its ID, then delete it
Uses bracket to ensure cleanup happens even if the action fails
The action runs in ExceptT for clean error handling
-}
withTestProjectCreate ::
TodoistConfig -> ProjectCreate -> (ProjectId -> ExceptT TodoistError IO a) -> IO ()
withTestProjectCreate config projectCreate action = do
let createProject = do
liftTodoist config (addProject projectCreate)
let deleteProject' projectId = do
void $ todoist config (Web.Todoist.Domain.Project.deleteProject projectId)
let runAction projectId = void $ assertSucceeds $ action projectId
bracket (assertSucceeds createProject) deleteProject' runAction
{- | Create a test project, run an action with its ID, then delete it
Uses bracket to ensure cleanup happens even if the action fails
The action runs in ExceptT for clean error handling
-}
withTestProject :: TodoistConfig -> Text -> (ProjectId -> ExceptT TodoistError IO a) -> IO ()
withTestProject config projectName action = do
let createProject = do
liftIO $ putStrLn $ "Creating test project: " <> show projectName
liftTodoist config (addProject $ runBuilder (createProjectBuilder projectName) mempty)
let deleteProject' projectId = do
liftIO $ putStrLn $ "Cleaning up test project: " <> show projectName
void $ todoist config (Web.Todoist.Domain.Project.deleteProject projectId)
let runAction projectId = void $ assertSucceeds $ action projectId
bracket (assertSucceeds createProject) deleteProject' runAction
{- | Create multiple test projects, run an action with their IDs, then delete all
Uses bracket to ensure cleanup of all projects even if the action fails
The action runs in ExceptT for clean error handling
-}
withTestProjects :: TodoistConfig -> [Text] -> ([ProjectId] -> ExceptT TodoistError IO a) -> IO ()
withTestProjects config projectNames action = do
let createProjects = do
liftIO $ putStrLn $ "Creating " <> show (L.length projectNames) <> " test projects"
mapM (\name -> liftTodoist config (addProject $ runBuilder (createProjectBuilder name) mempty)) projectNames
let deleteProjects projectIds = do
liftIO $ putStrLn $ "Cleaning up " <> show (L.length projectIds) <> " test projects"
mapM_ (todoist config . Web.Todoist.Domain.Project.deleteProject) projectIds
let runAction projectIds = void $ assertSucceeds $ action projectIds
bracket (assertSucceeds createProjects) deleteProjects runAction