+ def setUp(self) -> None:
+ super().setUp()
+ self._proc1_form_data: Any = self.post_process(1)
+ self._date = '2024-01-01'
+
+ @classmethod
+ def GET_todo_dict(cls,
+ target_id: int,
+ todos: list[dict[str, object]],
+ processes: list[dict[str, object]],
+ process_steps: list[dict[str, object]] | None = None,
+ conditions: list[dict[str, object]] | None = None
+ ) -> dict[str, object]:
+ """Return JSON of GET /todo to expect."""
+ # pylint: disable=too-many-arguments
+ library = {'Todo': cls.as_refs(todos),
+ 'Process': cls.as_refs(processes)}
+ if process_steps:
+ library['ProcessStep'] = cls.as_refs(process_steps)
+ conditions = conditions if conditions else []
+ if conditions:
+ library['Condition'] = cls.as_refs(conditions)
+ return {'todo': target_id,
+ 'steps_todo_to_process': [],
+ 'adoption_candidates_for': {},
+ 'process_candidates': [p['id'] for p in processes],
+ 'todo_candidates': [t['id'] for t in todos
+ if t['id'] != target_id],
+ 'condition_candidates': [c['id'] for c in conditions],
+ '_library': library}
+
+ @staticmethod
+ def _step_as_dict(node_id: int,
+ children: list[dict[str, object]],
+ process: int | None = None,
+ todo: int | None = None,
+ fillable: bool = False,
+ ) -> dict[str, object]:
+ return {'node_id': node_id,
+ 'children': children,
+ 'process': process,
+ 'fillable': fillable,
+ 'todo': todo}
+
+ def _make_todo_via_day_post(self, proc_id: int) -> None:
+ payload = {'day_comment': '',
+ 'new_todo': proc_id,
+ 'make_type': 'empty'}
+ self.check_post(payload, f'/day?date={self._date}&make_type=empty')
+
+ 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
+ self._make_todo_via_day_post(1)
+ for name in [
+ 'adopt', 'effort', 'make_full', 'make_empty', 'step_filler',
+ '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({'step_filler': f'{prefix}{suffix}'},
+ '/todo?id=1', 400, '/todo')
+
+ def test_basic_POST_todo(self) -> None:
+ """Test basic POST /todo manipulations."""
+ self._make_todo_via_day_post(1)
+ # test posting naked entity at first changes nothing
+ todo_dict = self.todo_as_dict(1, 1)
+ 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, 1, 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, 1, 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 Condition posts
+ c1_post = {'title': 'foo', 'description': 'oof', 'is_active': False}
+ c2_post = {'title': 'bar', 'description': 'rab', 'is_active': True}
+ self.check_post(c1_post, '/condition', redir='/condition?id=1')
+ self.check_post(c2_post, '/condition', redir='/condition?id=2')
+ c1_dict = self.cond_as_dict(1, False, ['foo'], ['oof'])
+ c2_dict = self.cond_as_dict(2, True, ['bar'], ['rab'])
+ conditions = [c1_dict, c2_dict]
+ todo_post = {'conditions': [1], 'disables': [1],
+ 'blockers': [2], 'enables': [2]}
+ for k, v in todo_post.items():
+ todo_dict[k] = v
+ self.check_post(todo_post, '/todo?id=1')
+ expected = self.GET_todo_dict(1, [todo_dict], [proc_dict],
+ conditions=conditions)
+ self.check_json_get('/todo?id=1', expected)
+
+ def test_POST_todo_deletion(self) -> None:
+ """Test deletions via POST /todo."""
+ self._make_todo_via_day_post(1)
+ todo_dict = self.todo_as_dict(1, process_id=1)
+ proc_dict = self.proc_as_dict(**self._proc1_form_data)
+ expected = self.GET_todo_dict(1, [todo_dict], [proc_dict])
+ # 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._make_todo_via_day_post(1)
+ self._make_todo_via_day_post(1)
+ 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._make_todo_via_day_post(1)
+ 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)
+ # test cannot delete Todo with comment or effort
+ self.check_post({'comment': 'foo'}, '/todo?id=2')
+ self.check_post({'delete': ''}, '/todo?id=2', 500, '/')
+ self.check_post({'effort': 5}, '/todo?id=2')
+ self.check_post({'delete': ''}, '/todo?id=2', 500, '/')
+ # test deletion via effort < 0, but only once deletable
+ self.check_post({'effort': -1, 'comment': 'foo'}, '/todo?id=2')
+ todo_dict['comment'] = 'foo'
+ todo_dict['effort'] = -1
+ self.check_json_get('/todo?id=2', expected)
+ self.check_post({}, '/todo?id=2')
+ self.check_get('/todo?id=2', 404)
+
+ def test_POST_todo_adoption(self) -> None:
+ """Test adoption via POST /todo with "adopt"."""
+ # pylint: disable=too-many-locals
+ # pylint: disable=too-many-statements
+ # post two Todos to Day, have first adopt second
+ self._make_todo_via_day_post(1)
+ self._make_todo_via_day_post(1)
+ proc1_dict = self.proc_as_dict(**self._proc1_form_data)
+ todo1_dict = self.todo_as_dict(1, process_id=1, children=[2])
+ todo2_dict = self.todo_as_dict(2, process_id=1, parents=[1])
+ todos = [todo1_dict, todo2_dict]
+ expected = self.GET_todo_dict(1, todos, [proc1_dict])
+ expected['steps_todo_to_process'] = [self._step_as_dict(1, [], todo=2)]
+ self.check_post({'adopt': 2}, '/todo?id=1')
+ 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')
+ todo1_dict['parents'] = [2]
+ todo2_dict['children'] = [1]
+ self.check_post({'adopt': 2}, '/todo?id=1', 400)
+ # test cannot do 2-step circular adoption
+ self._make_todo_via_day_post(1)
+ self.check_post({'adopt': 2}, '/todo?id=3')
+ todo3_dict = self.todo_as_dict(3, process_id=1, children=[2])
+ todo2_dict['parents'] = [3]
+ todos += [todo3_dict]
+ self.check_post({'adopt': 3}, '/todo?id=1', 400)
+ # test can adopt Todo into ProcessStep chain via its Process (with key
+ # 'step_filler' equivalent to single-element 'adopt' if intable)
+ proc_post = {'title': 'A', 'description': '', 'effort': 1.0}
+ self.post_process(3, proc_post)
+ self.post_process(2, proc_post)
+ self.post_process(1, self._proc1_form_data | {'new_top_step': [2, 3]})
+ self._make_todo_via_day_post(2)
+ self._make_todo_via_day_post(3)
+ self.check_post({'step_filler': 5, 'adopt': [4]}, '/todo?id=1')
+ proc3_dict = self.proc_as_dict(3)
+ proc2_dict = self.proc_as_dict(2)
+ proc1_dict['explicit_steps'] = [1, 2]
+ procs = [proc1_dict, proc2_dict, proc3_dict]
+ procsteps = [self.procstep_as_dict(1, 1, 2),
+ self.procstep_as_dict(2, 1, 3)]
+ todo1_dict['children'] = [4, 5]
+ todo4_dict = self.todo_as_dict(4, process_id=2, parents=[1])
+ todo5_dict = self.todo_as_dict(5, process_id=3, parents=[1])
+ todos += [todo4_dict, todo5_dict]
+ expected = self.GET_todo_dict(1, todos, procs, procsteps)
+ step_proc2 = self._step_as_dict(1, [], 2, 4, True)
+ step_proc3 = self._step_as_dict(2, [], 3, 5, True)
+ expected['steps_todo_to_process'] = [step_proc2, step_proc3]
+ self.check_json_get('/todo?id=1', expected)
+ # test 'ignore' values for 'step_filler' are ignored, and intable
+ # 'step_filler' values are interchangeable with those of 'adopt'
+ todo_post = {'adopt': 5, 'step_filler': ['ignore', 4]}
+ self.check_post(todo_post, '/todo?id=1')
+ self.check_json_get('/todo?id=1', expected)
+ # test cannot adopt into non-top-level elements of chain
+ self.post_process(4, proc_post)
+ self.post_process(3, proc_post | {'new_top_step': 4, 'step_of': [1]})
+ proc4_dict = self.proc_as_dict(4)
+ proc3_dict['explicit_steps'] = [3]
+ procs += [proc4_dict]
+ procsteps += [self.procstep_as_dict(3, 3, 4)]
+ step_proc4 = self._step_as_dict(3, [], 4, None, True)
+ step_proc3['children'] = [step_proc4]
+ self._make_todo_via_day_post(4)
+ self.check_post({'adopt': [4, 5, 6]}, '/todo?id=1')
+ todo6_dict = self.todo_as_dict(6, process_id=4, parents=[1])
+ todo1_dict['children'] = [4, 5, 6]
+ todos += [todo6_dict]
+ expected = self.GET_todo_dict(1, todos, procs, procsteps)
+ step2_proc4 = self._step_as_dict(4, [], None, 6, False)
+ expected['steps_todo_to_process'] = [step_proc2, step_proc3,
+ step2_proc4]
+ expected['adoption_candidates_for'] = {'4': [6]}
+ self.check_json_get('/todo?id=1', expected)
+
+ def test_POST_todo_make_full(self) -> None:
+ """Test creation and adoption via POST /todo with "make_full"."""
+ # pylint: disable=too-many-locals
+ # create chain of Processes
+ proc_post = {'title': 'A', 'description': '', 'effort': 1.0}
+ self.post_process(2, proc_post | {'new_top_step': 1})
+ self.post_process(3, proc_post | {'new_top_step': 2})
+ self.post_process(4, proc_post | {'new_top_step': 3})
+ proc1_dict = self.proc_as_dict(**self._proc1_form_data)
+ proc2_dict = self.proc_as_dict(2, explicit_steps=[1])
+ proc3_dict = self.proc_as_dict(3, explicit_steps=[2])
+ proc4_dict = self.proc_as_dict(4, explicit_steps=[3])
+ procs = [proc1_dict, proc2_dict, proc3_dict, proc4_dict]
+ procsteps = [self.procstep_as_dict(1, 2, 1),
+ self.procstep_as_dict(2, 3, 2),
+ self.procstep_as_dict(3, 4, 3)]
+ # post (childless) Todo of chain end, then make_full on next in line
+ self._make_todo_via_day_post(4)
+ todo1_dict = self.todo_as_dict(1, 4, children=[2])
+ todo2_dict = self.todo_as_dict(2, 3, children=[3], parents=[1])
+ todo3_dict = self.todo_as_dict(3, 2, parents=[2], children=[4])
+ todo4_dict = self.todo_as_dict(4, 1, parents=[3])
+ todos = [todo1_dict, todo2_dict, todo3_dict, todo4_dict]
+ expected = self.GET_todo_dict(1, todos, procs, procsteps)
+ step_proc1 = self._step_as_dict(3, [], 1, 4, True)
+ step_proc2 = self._step_as_dict(2, [step_proc1], 2, 3, True)
+ step_proc3 = self._step_as_dict(1, [step_proc2], 3, 2, True)
+ expected['steps_todo_to_process'] = [step_proc3]
+ self.check_post({'step_filler': 'make_full_3'}, '/todo?id=1')
+ self.check_json_get('/todo?id=1', expected)
+ # make new chain next to expected, find steps_todo_to_process extended,
+ # expect existing Todo demanded by new chain be adopted into new chain
+ self.check_post({'make_full': 2, 'adopt': [2]}, '/todo?id=1')
+ todo5_dict = self.todo_as_dict(5, 2, parents=[1], children=[4])
+ todo1_dict['children'] = [2, 5]
+ todo4_dict['parents'] = [3, 5]
+ todos += [todo5_dict]
+ step2_proc1 = self._step_as_dict(5, [], None, 4)
+ step2_proc2 = self._step_as_dict(4, [step2_proc1], None, 5)
+ expected = self.GET_todo_dict(1, todos, procs, procsteps)
+ expected['steps_todo_to_process'] = [step_proc3, step2_proc2]
+ self.check_json_get('/todo?id=1', expected)
+ # fail on trying to call make_full on non-existing Process
+ self.check_post({'make_full': 5}, '/todo?id=1', 404)
+
+ def test_POST_todo_make_empty(self) -> None:
+ """Test creation and adoption via POST /todo with "make_empty"."""
+ # pylint: disable=too-many-locals
+ # create chain of Processes
+ proc_post = {'title': 'A', 'description': '', 'effort': 1.0}
+ self.post_process(2, proc_post | {'new_top_step': 1})
+ self.post_process(3, proc_post | {'new_top_step': 2})
+ self.post_process(4, proc_post | {'new_top_step': 3})
+ proc1_dict = self.proc_as_dict(**self._proc1_form_data)
+ proc2_dict = self.proc_as_dict(2, explicit_steps=[1])
+ proc3_dict = self.proc_as_dict(3, explicit_steps=[2])
+ proc4_dict = self.proc_as_dict(4, explicit_steps=[3])
+ procs = [proc1_dict, proc2_dict, proc3_dict, proc4_dict]
+ procsteps = [self.procstep_as_dict(1, 2, 1),
+ self.procstep_as_dict(2, 3, 2),
+ self.procstep_as_dict(3, 4, 3)]
+ # post (childless) Todo of chain end, then make empty on next in line
+ self._make_todo_via_day_post(4)
+ todo1_dict = self.todo_as_dict(1, 4, children=[2])
+ todo2_dict = self.todo_as_dict(2, 3, parents=[1])
+ todos = [todo1_dict, todo2_dict]
+ expected = self.GET_todo_dict(1, todos, procs, procsteps)
+ step_proc1 = self._step_as_dict(3, [], 1, None)
+ step_proc2 = self._step_as_dict(2, [step_proc1], 2, None, True)
+ step_proc3 = self._step_as_dict(1, [step_proc2], 3, 2, True)
+ expected['steps_todo_to_process'] = [step_proc3]
+ expected['adoption_candidates_for'] = {'1': [], '2': []}
+ self.check_post({'step_filler': 'make_empty_3'}, '/todo?id=1')
+ self.check_json_get('/todo?id=1', expected)
+ # make new top-level Todo without chain implied by its Process
+ self.check_post({'make_empty': 2, 'adopt': [2]}, '/todo?id=1')
+ todo3_dict = self.todo_as_dict(3, 2, parents=[1], children=[])
+ todo1_dict['children'] = [2, 3]
+ todos += [todo3_dict]
+ step2_proc2 = self._step_as_dict(4, [], None, 3)
+ expected = self.GET_todo_dict(1, todos, procs, procsteps)
+ expected['steps_todo_to_process'] = [step_proc3, step2_proc2]
+ expected['adoption_candidates_for'] = {'1': [], '2': [3]}
+ self.check_json_get('/todo?id=1', expected)
+ # fail on trying to call make_empty on non-existing Process
+ self.check_post({'make_full': 5}, '/todo?id=1', 404)