home · contact · privacy
Extend Todo tests.
[plomtask] / tests / todos.py
index 0998c690494e60ae864e929e205c16549b4ebe9a..aec7366d14760c8bb61fc6229a09f43332222bcf 100644 (file)
@@ -1,4 +1,5 @@
 """Test Todos module."""
+from typing import Any
 from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
 from plomtask.todos import Todo, TodoNode
 from plomtask.processes import Process, ProcessStep
@@ -10,15 +11,12 @@ from plomtask.exceptions import (NotFoundException, BadFormatException,
 class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
     """Tests requiring DB, but not server setup.
 
-    NB: We subclass TestCaseSansDB too, to pull in its .test_id_validation,
-    which for Todo wouldn't run without a DB being set up due to the need for
-    Processes with set IDs.
+    NB: We subclass TestCaseSansDB too, to run any tests there that due to any
+    Todo requiring a _saved_ Process wouldn't run without a DB.
     """
     checked_class = Todo
     default_init_kwargs = {'process': None, 'is_done': False,
                            'date': '2024-01-01'}
-    # solely used for TestCaseSansDB.test_id_setting
-    default_init_args = [None, False, '2024-01-01']
 
     def setUp(self) -> None:
         super().setUp()
@@ -31,7 +29,6 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         self.cond2 = Condition(None)
         self.cond2.save(self.db_conn)
         self.default_init_kwargs['process'] = self.proc
-        self.default_init_args[0] = self.proc
 
     def test_Todo_init(self) -> None:
         """Test creation of Todo and what they default to."""
@@ -64,6 +61,10 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         with self.assertRaises(BadFormatException):
             self.assertEqual(Todo.by_date(self.db_conn, 'foo'), [])
 
+    def test_Todo_by_date_range_with_limits(self) -> None:
+        """Test .by_date_range_with_limits."""
+        self.check_by_date_range_with_limits('day')
+
     def test_Todo_on_conditions(self) -> None:
         """Test effect of Todos on Conditions."""
         assert isinstance(self.cond1.id_, int)
@@ -120,16 +121,24 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
 
     def test_Todo_step_tree(self) -> None:
         """Test self-configuration of TodoStepsNode tree for Day view."""
+
+        def todo_node_as_dict(node: TodoNode) -> dict[str, object]:
+            return {'todo': node.todo.id_, 'seen': node.seen,
+                    'children': [todo_node_as_dict(c) for c in node.children]}
+
         todo_1 = Todo(None, self.proc, False, self.date1)
         todo_1.save(self.db_conn)
         assert isinstance(todo_1.id_, int)
         # test minimum
         node_0 = TodoNode(todo_1, False, [])
-        self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+        cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set()))
+        cmp_1_dict = todo_node_as_dict(node_0)
+        self.assertEqual(cmp_0_dict, cmp_1_dict)
         # test non_emtpy seen_todo does something
         node_0.seen = True
-        self.assertEqual(todo_1.get_step_tree({todo_1.id_}).as_dict,
-                         node_0.as_dict)
+        cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree({todo_1.id_}))
+        cmp_1_dict = todo_node_as_dict(node_0)
+        self.assertEqual(cmp_0_dict, cmp_1_dict)
         # test child shows up
         todo_2 = Todo(None, self.proc, False, self.date1)
         todo_2.save(self.db_conn)
@@ -138,7 +147,9 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         node_2 = TodoNode(todo_2, False, [])
         node_0.children = [node_2]
         node_0.seen = False
-        self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+        cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set()))
+        cmp_1_dict = todo_node_as_dict(node_0)
+        self.assertEqual(cmp_0_dict, cmp_1_dict)
         # test child shows up with child
         todo_3 = Todo(None, self.proc, False, self.date1)
         todo_3.save(self.db_conn)
@@ -146,12 +157,16 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         todo_2.add_child(todo_3)
         node_3 = TodoNode(todo_3, False, [])
         node_2.children = [node_3]
-        self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+        cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set()))
+        cmp_1_dict = todo_node_as_dict(node_0)
+        self.assertEqual(cmp_0_dict, cmp_1_dict)
         # test same todo can be child-ed multiple times at different locations
         todo_1.add_child(todo_3)
         node_4 = TodoNode(todo_3, True, [])
         node_0.children += [node_4]
-        self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+        cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set()))
+        cmp_1_dict = todo_node_as_dict(node_0)
+        self.assertEqual(cmp_0_dict, cmp_1_dict)
 
     def test_Todo_create_with_children(self) -> None:
         """Test parenthood guaranteeds of Todo.create_with_children."""
@@ -236,90 +251,193 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
 class TestsWithServer(TestCaseWithServer):
     """Tests against our HTTP server/handler (and database)."""
 
+    def setUp(self) -> None:
+        super().setUp()
+        self._proc1_form_data: Any = self.post_process(1)
+
+    @classmethod
+    def GET_todo_dict(cls,
+                      target_id: int,
+                      todos: list[dict[str, object]],
+                      processes: list[dict[str, object]]
+                      ) -> dict[str, object]:
+        """Return JSON of GET /todo to expect."""
+        library = {'Todo': cls.as_refs(todos),
+                   'Process': cls.as_refs(processes)}
+        return {'todo': target_id,
+                'steps_todo_to_process': [],
+                'adoption_candidates_for': {},
+                'process_candidates': [p['id'] for p in processes],
+                'todo_candidates': [],
+                'condition_candidates': [],
+                '_library': library}
+
+    def test_basic_fail_POST_todo(self) -> None:
+        """Test basic malformed/illegal POST /todo requests."""
+        # test we cannot just POST into non-existing Todo
+        self.check_post({}, '/todo', 404)
+        self.check_post({}, '/todo?id=FOO', 400)
+        self.check_post({}, '/todo?id=0', 404)
+        self.check_post({}, '/todo?id=1', 404)
+        # test malformed values on existing Todo
+        day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
+        self.check_post(day_post, '/day?date=2024-01-01&make_type=full')
+        for name in ['adopt', 'effort', 'make_full', 'make_empty',
+                     'conditions', 'disables', 'blockers', 'enables']:
+            self.check_post({name: 'x'}, '/todo?id=1', 400, '/todo')
+        for prefix in ['make_empty_', 'make_full_']:
+            for suffix in ['', 'x', '1.1']:
+                self.check_post({'fill_for_1': f'{prefix}{suffix}'},
+                                '/todo?id=1', 400, '/todo')
+
+    def test_basic_POST_todo(self) -> None:
+        """Test POST /todo."""
+        date = '2024-01-01'
+        day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        # test posting naked entity at first changes nothing
+        todo_dict = self.todo_as_dict(1, process_id=1, date=date)
+        proc_dict = self.proc_as_dict(**self._proc1_form_data)
+        expected = self.GET_todo_dict(1, [todo_dict], [proc_dict])
+        self.check_json_get('/todo?id=1', expected)
+        self.check_post({}, '/todo?id=1')
+        self.check_json_get('/todo?id=1', expected)
+        # test posting doneness, comment, calendarization, effort
+        todo_post = {'done': '', 'calendarize': '', 'comment': 'foo',
+                     'effort': 2.3}
+        todo_dict = self.todo_as_dict(
+                1, process_id=1, date=date, is_done=True, calendarize=True,
+                comment='foo', effort=2.3)
+        expected = self.GET_todo_dict(1, [todo_dict], [proc_dict])
+        self.check_post(todo_post, '/todo?id=1')
+        self.check_json_get('/todo?id=1', expected)
+        # test implicitly un-setting all of those except effort by empty post
+        self.check_post({}, '/todo?id=1')
+        todo_dict = self.todo_as_dict(1, process_id=1, date=date, effort=2.3)
+        expected = self.GET_todo_dict(1, [todo_dict], [proc_dict])
+        self.check_json_get('/todo?id=1', expected)
+        # test empty effort post can be explicitly unset by "" post
+        self.check_post({'effort': ''}, '/todo?id=1')
+        todo_dict['effort'] = None
+        self.check_json_get('/todo?id=1', expected)
+        # test failure of deletion on non-existing Todo
+        self.check_post({'delete': ''}, '/todo?id=2', 404, '/')
+        # test deletion of existing Todo
+        self.check_post({'delete': ''}, '/todo?id=1', 302, '/')
+        self.check_get('/todo?id=1', 404)
+        # test deletion of adopted Todo
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        self.check_post({'adopt': 2}, '/todo?id=1')
+        self.check_post({'delete': ''}, '/todo?id=2', 302, '/')
+        self.check_json_get('/todo?id=1', expected)
+        # test deletion of adopting Todo
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        self.check_post({'adopt': 2}, '/todo?id=1')
+        self.check_post({'delete': ''}, '/todo?id=1', 302, '/')
+        todo_dict['id'] = 2
+        expected = self.GET_todo_dict(2, [todo_dict], [proc_dict])
+        self.check_json_get('/todo?id=2', expected)
+
+    def test_POST_todo_adoption(self) -> None:
+        """Test POST /todo."""
+        date = '2024-01-01'
+        # post two Todos to Day, have first adopt second
+        proc_dict = self.proc_as_dict(**self._proc1_form_data)
+        day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        todo1_dict = self.todo_as_dict(1, process_id=1, date=date)
+        todo1_dict['children'] = [2]
+        todo2_dict = self.todo_as_dict(2, process_id=1, date=date)
+        todo2_dict['parents'] = [1]
+        expected = self.GET_todo_dict(1, [todo1_dict, todo2_dict], [proc_dict])
+        expected['todo_candidates'] = [2]
+        expected['steps_todo_to_process'] = [{
+            'children': [], 'fillable': False,
+            'node_id': 1, 'process': None, 'todo': 2}]
+        self.check_post({'adopt': 2}, '/todo?id=1')
+        self.check_json_get('/todo?id=1', expected)
+        # test Todo cannot be set done with adopted Todo not done yet
+        self.check_post({'adopt': 2, 'done': ''}, '/todo?id=1', 400)
+        self.check_json_get('/todo?id=1', expected)
+        # test Todo un-adopting by just not sending an adopt
+        self.check_post({}, '/todo?id=1')
+        todo1_dict['children'] = []
+        todo2_dict['parents'] = []
+        expected['steps_todo_to_process'] = []
+        self.check_json_get('/todo?id=1', expected)
+        # test fail on trying to adopt non-existing Todo
+        self.check_post({'adopt': 3}, '/todo?id=1', 404)
+        # test cannot self-adopt
+        self.check_post({'adopt': 1}, '/todo?id=1', 400)
+        # test cannot do 1-step circular adoption
+        self.check_post({'adopt': 1}, '/todo?id=2')
+        self.check_post({'adopt': 2}, '/todo?id=1', 400)
+        # test cannot do 2-step circular adoption
+        day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        self.check_post({'adopt': 2}, '/todo?id=3')
+        self.check_post({'adopt': 3}, '/todo?id=1', 400)
+
+    def test_do_GET_todo(self) -> None:
+        """Test GET /todo response codes."""
+        date = '2024-01-01'
+        day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
+        self.check_post(day_post, f'/day?date={date}&make_type=full')
+        # test malformed or illegal parameter values
+        self.check_get('/todo', 404)
+        self.check_get('/todo?id=', 404)
+        self.check_get('/todo?id=foo', 400)
+        self.check_get('/todo?id=0', 404)
+        self.check_get('/todo?id=2', 404)
+        # test all existing Processes are shown as available
+        p2_post: Any = {'title': 'bar', 'description': 'baz', 'effort': 0.9}
+        self.post_process(2, p2_post)
+        todo1_dict = self.todo_as_dict(1, process_id=1, date=date)
+        proc1_dict = self.proc_as_dict(1, **self._proc1_form_data)
+        proc2_dict = self.proc_as_dict(2, **p2_post)
+        expected = self.GET_todo_dict(1, [todo1_dict], [proc1_dict,
+                                                        proc2_dict])
+        self.check_json_get('/todo?id=1', expected)
+
     def test_do_POST_day(self) -> None:
         """Test Todo posting of POST /day."""
-        self.post_process()
         self.post_process(2)
         proc = Process.by_id(self.db_conn, 1)
         proc2 = Process.by_id(self.db_conn, 2)
+        # check posting no Todos to Day makes Todo.by_date return empty list
         form_data = {'day_comment': '', 'make_type': 'full'}
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
         self.assertEqual(Todo.by_date(self.db_conn, '2024-01-01'), [])
+        proc = Process.by_id(self.db_conn, 1)
+        # post Todo to Day and check its display
         form_data['new_todo'] = str(proc.id_)
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
         todos = Todo.by_date(self.db_conn, '2024-01-01')
         self.assertEqual(1, len(todos))
         todo1 = todos[0]
         self.assertEqual(todo1.id_, 1)
+        proc = Process.by_id(self.db_conn, 1)
         self.assertEqual(todo1.process.id_, proc.id_)
         self.assertEqual(todo1.is_done, False)
+        # post second Todo, check its appearance
+        proc2 = Process.by_id(self.db_conn, 2)
         form_data['new_todo'] = str(proc2.id_)
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
         todos = Todo.by_date(self.db_conn, '2024-01-01')
         todo1 = todos[1]
         self.assertEqual(todo1.id_, 2)
+        proc2 = Process.by_id(self.db_conn, 1)
+        todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
+        self.assertEqual(todo1.id_, 1)
         self.assertEqual(todo1.process.id_, proc2.id_)
         self.assertEqual(todo1.is_done, False)
 
-    def test_do_POST_todo(self) -> None:
-        """Test POST /todo."""
-        def post_and_reload(form_data: dict[str, object], status: int = 302,
-                            redir_url: str = '/todo?id=1') -> Todo:
-            self.check_post(form_data, '/todo?id=1', status, redir_url)
-            return Todo.by_date(self.db_conn, '2024-01-01')[0]
-        # test minimum
-        self.post_process()
-        self.check_post({'day_comment': '', 'new_todo': 1,
-                         'make_type': 'full'},
-                        '/day?date=2024-01-01&make_type=full', 302)
-        # test posting to bad URLs
-        self.check_post({}, '/todo=', 404)
-        self.check_post({}, '/todo?id=', 404)
-        self.check_post({}, '/todo?id=FOO', 400)
-        self.check_post({}, '/todo?id=0', 404)
-        # test posting naked entity
-        todo1 = post_and_reload({})
-        self.assertEqual(todo1.children, [])
-        self.assertEqual(todo1.parents, [])
-        self.assertEqual(todo1.is_done, False)
-        # test posting doneness
-        todo1 = post_and_reload({'done': ''})
-        self.assertEqual(todo1.is_done, True)
-        # test implicitly posting non-doneness
-        todo1 = post_and_reload({})
-        self.assertEqual(todo1.is_done, False)
-        # test malformed adoptions
-        self.check_post({'adopt': 'foo'}, '/todo?id=1', 400)
-        self.check_post({'adopt': 1}, '/todo?id=1', 400)
-        self.check_post({'adopt': 2}, '/todo?id=1', 404)
-        # test posting second todo of same process
-        self.check_post({'day_comment': '', 'new_todo': 1,
-                         'make_type': 'full'},
-                        '/day?date=2024-01-01&make_type=full', 302)
-        # test todo 1 adopting todo 2
-        todo1 = post_and_reload({'adopt': 2})
-        todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
-        self.assertEqual(todo1.children, [todo2])
-        self.assertEqual(todo1.parents, [])
-        self.assertEqual(todo2.children, [])
-        self.assertEqual(todo2.parents, [todo1])
-        # test todo1 cannot be set done with todo2 not done yet
-        todo1 = post_and_reload({'done': '', 'adopt': 2}, 400)
-        self.assertEqual(todo1.is_done, False)
-        # test todo1 un-adopting todo 2 by just not sending an adopt
-        todo1 = post_and_reload({}, 302)
-        todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
-        self.assertEqual(todo1.children, [])
-        self.assertEqual(todo1.parents, [])
-        self.assertEqual(todo2.children, [])
-        self.assertEqual(todo2.parents, [])
-        # test todo1 deletion
-        todo1 = post_and_reload({'delete': ''}, 302, '/')
-
     def test_do_POST_day_todo_adoption(self) -> None:
         """Test Todos posted to Day view may adopt existing Todos."""
-        form_data = self.post_process()
-        form_data = self.post_process(2, form_data | {'new_top_step': 1})
+        form_data = self.post_process(
+                2, self._proc1_form_data | {'new_top_step': 1})
         form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
         form_data['new_todo'] = 2
@@ -333,7 +451,7 @@ class TestsWithServer(TestCaseWithServer):
 
     def test_do_POST_day_todo_multiple(self) -> None:
         """Test multiple Todos can be posted to Day view."""
-        form_data = self.post_process()
+        form_data = self.post_process()
         form_data = self.post_process(2)
         form_data = {'day_comment': '', 'new_todo': [1, 2],
                      'make_type': 'full'}
@@ -387,8 +505,7 @@ class TestsWithServer(TestCaseWithServer):
             self.assertEqual(todo3.children, [])
             self.assertEqual(sorted(todo3.parents), sorted([todo2, todo1]))
 
-        form_data = self.post_process()
-        form_data = self.post_process(2, form_data | {'new_top_step': 1})
+        self.post_process(2, self._proc1_form_data | {'new_top_step': 1})
         check_adoption('2024-01-01', [1, 2])
         check_adoption('2024-01-02', [2, 1])
         check_nesting_adoption(3, '2024-01-03', [1, 2])
@@ -396,26 +513,16 @@ class TestsWithServer(TestCaseWithServer):
 
     def test_do_POST_day_todo_doneness(self) -> None:
         """Test Todo doneness can be posted to Day view."""
-        self.post_process()
         form_data = {'day_comment': '', 'new_todo': [1], 'make_type': 'full'}
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
         todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
         form_data = {'day_comment': '', 'todo_id': [1], 'make_type': 'full',
                      'comment': [''], 'done': [], 'effort': ['']}
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
+        todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
         self.assertEqual(todo.is_done, False)
         form_data = {'day_comment': '', 'todo_id': [1], 'done': [1],
                      'make_type': 'full', 'comment': [''], 'effort': ['']}
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
+        todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
         self.assertEqual(todo.is_done, True)
-
-    def test_do_GET_todo(self) -> None:
-        """Test GET /todo response codes."""
-        self.post_process()
-        form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
-        self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
-        self.check_get('/todo', 404)
-        self.check_get('/todo?id=', 404)
-        self.check_get('/todo?id=foo', 400)
-        self.check_get('/todo?id=0', 404)
-        self.check_get('/todo?id=1', 200)