{-# 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