|
|
@@ -0,0 +1,461 @@
|
|
|
+"""The projectdelete bundle: admin-only project deletion with a STRICTLY
|
|
|
+ownership-based cascade. The deletion takes the project row and the rows
|
|
|
+it owns (its tasks, its goals, and those rows' children); rows in OTHER
|
|
|
+projects that merely reference the deleted project (co-developed /
|
|
|
+reference links, stack membership) are NEVER taken — only the pointer
|
|
|
+rows naming it are tidied away. Covers the API wire (auth, 404, the
|
|
|
+live-task guard), the cascade's correctness, the cross-project survival
|
|
|
+law, standalone-task immunity, and the page's typed-name confirmation.
|
|
|
+"""
|
|
|
+from harness import * # noqa: F401,F403 — the shared wire helpers
|
|
|
+
|
|
|
+
|
|
|
+def sql_one(query, params=()):
|
|
|
+ """The first column of the first row, or None."""
|
|
|
+ conn = db()
|
|
|
+ try:
|
|
|
+ row = conn.execute(query, params).fetchone()
|
|
|
+ return row[0] if row is not None else None
|
|
|
+ finally:
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+
|
|
|
+def sql_all(query, params=()):
|
|
|
+ conn = db()
|
|
|
+ try:
|
|
|
+ return conn.execute(query, params).fetchall()
|
|
|
+ finally:
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+
|
|
|
+def sql_exec(*statements):
|
|
|
+ """Runs (sql, params...) statements in one commit; params are the
|
|
|
+ trailing tuple entry when present."""
|
|
|
+ conn = db()
|
|
|
+ try:
|
|
|
+ for statement in statements:
|
|
|
+ sql = statement[0]
|
|
|
+ params = statement[1] if len(statement) > 1 else ()
|
|
|
+ if not isinstance(params, (tuple, list)):
|
|
|
+ params = (params,)
|
|
|
+ conn.execute(sql, tuple(params))
|
|
|
+ conn.commit()
|
|
|
+ finally:
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+
|
|
|
+def make_project(name):
|
|
|
+ """Creates a project row straight in the DB, returning its id."""
|
|
|
+ sql_exec(("insert into projects (name, git_url, default_branch, created_at)"
|
|
|
+ " values (?, ?, 'main', strftime('%s','now'))",
|
|
|
+ (name, "https://example.com/%s.git" % name)))
|
|
|
+ return int(sql_one("select id from projects where name = ?", (name,)))
|
|
|
+
|
|
|
+
|
|
|
+def delete_api(auth, project_id):
|
|
|
+ """DELETE /api/projects/{id} with the caller's statum session."""
|
|
|
+ status, body = http("DELETE", "/api/projects/%d" % project_id, None, {
|
|
|
+ "X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
|
|
|
+ return status, parse_json(body)
|
|
|
+
|
|
|
+
|
|
|
+def parse_json(text):
|
|
|
+ """A JSON object body, or {} when the body is empty or not JSON."""
|
|
|
+ try:
|
|
|
+ parsed = json.loads(text)
|
|
|
+ return parsed if isinstance(parsed, dict) else {}
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ return {}
|
|
|
+
|
|
|
+
|
|
|
+def run(ctx):
|
|
|
+ auth, fake = ctx.auth, ctx.fake
|
|
|
+
|
|
|
+ print("== the API wire: auth, 404, method ==")
|
|
|
+ # Unauthenticated: no slot header at all.
|
|
|
+ status, body = http("DELETE", "/api/projects/1")
|
|
|
+ body = parse_json(body)
|
|
|
+ check(status == 401 and body.get("ok") is False,
|
|
|
+ "DELETE without a session answers 401 JSON")
|
|
|
+
|
|
|
+ # Unknown project id: 404 for the admin.
|
|
|
+ status, body = delete_api(auth, 999999)
|
|
|
+ check(status == 404 and "no project exists" in body.get("error", "").lower(),
|
|
|
+ "DELETE of an unknown project answers 404")
|
|
|
+
|
|
|
+ # A non-admin user (created through the users page) is refused 403
|
|
|
+ # even with a genuine session.
|
|
|
+ users_state = slot_public(auth, "/users", "users")
|
|
|
+ act(auth, users_state["create"], {
|
|
|
+ "username": "nonadmin", "email": "nonadmin@example.com",
|
|
|
+ "forename": "Non", "surname": "Admin",
|
|
|
+ "date_of_birth": "1990-01-01",
|
|
|
+ "password": "nonadmin1", "confirm_password": "nonadmin1"})
|
|
|
+ plain = login("nonadmin", "nonadmin1")
|
|
|
+ victim_id = make_project("auth-probe")
|
|
|
+ status, body = delete_api(plain, victim_id)
|
|
|
+ check(status == 403, "DELETE by a non-admin answers 403")
|
|
|
+ check(sql_one("select count(*) from projects where id = ?", (victim_id,)) == 1,
|
|
|
+ "the refused DELETE left the project row alone")
|
|
|
+ sql_exec(("delete from projects where id = ?", (victim_id,)))
|
|
|
+
|
|
|
+ print("== cascade: the project and its OWNED rows go, in full ==")
|
|
|
+ # The doomed project owns: two plain tasks (with comments, blocks,
|
|
|
+ # rule attachments, capability requirements, sessions + events) and
|
|
|
+ # one goal (with a goal-run task, goal links, goal-scoped events).
|
|
|
+ doomed = make_project("doomed")
|
|
|
+ keeper = make_project("keeper")
|
|
|
+ other = make_project("other-host") # hosts cross-project survivors
|
|
|
+ standalone_host = make_project("standalone-host")
|
|
|
+
|
|
|
+ sql_exec(
|
|
|
+ ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
|
|
|
+ " description, status, priority, created_at)"
|
|
|
+ " values (?, 0, 'task', 'implement', 'human', 'Doomed draft',"
|
|
|
+ " 'x', 'draft', 5, strftime('%s','now'))", (doomed,)),
|
|
|
+ ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
|
|
|
+ " description, status, priority, created_at)"
|
|
|
+ " values (?, 0, 'task', 'implement', 'human', 'Doomed done',"
|
|
|
+ " 'x', 'done', 5, strftime('%s','now'))", (doomed,)),
|
|
|
+ ("insert into goals (project_id, title, brief, branch_name, status,"
|
|
|
+ " creator, created_at)"
|
|
|
+ " values (?, 'Doomed goal', 'Everything goes.', 'goal/doomed',"
|
|
|
+ " 'complete', 'human', strftime('%s','now'))", (doomed,)),
|
|
|
+ )
|
|
|
+ doomed_goal = int(sql_one("select id from goals where project_id = ?", (doomed,)))
|
|
|
+ sql_exec(
|
|
|
+ # The goal's own goal-run task (owned via project_id AND goal_id).
|
|
|
+ ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
|
|
|
+ " description, status, priority, created_at)"
|
|
|
+ " values (?, ?, 'goal_run', 'orchestrate', 'goal', 'Goal run',"
|
|
|
+ " 'x', 'done', 3, strftime('%s','now'))", (doomed, doomed_goal)),
|
|
|
+ # A goal_links row on the doomed goal pointing at `keeper`.
|
|
|
+ ("insert into goal_links (goal_id, project_id, relationship)"
|
|
|
+ " values (?, ?, 'reference')", (doomed_goal, keeper)),
|
|
|
+ )
|
|
|
+ doomed_tasks = [int(row[0]) for row in sql_all(
|
|
|
+ "select id from tasks where project_id = ?", (doomed,))]
|
|
|
+
|
|
|
+ # Child rows of the first doomed task: comment, block, rule attach,
|
|
|
+ # capability attach, a session and events (session-carried AND
|
|
|
+ # task-scoped).
|
|
|
+ first_task = doomed_tasks[0]
|
|
|
+ sql_exec(
|
|
|
+ ("insert into rules (name, body, created_at)"
|
|
|
+ " values ('doomed-rule', 'body', strftime('%s','now'))",),
|
|
|
+ ("insert into capabilities (name, created_at)"
|
|
|
+ " values ('doomed-cap', strftime('%s','now'))",),
|
|
|
+ ("insert into task_comments (task_id, author, body, created_at)"
|
|
|
+ " values (?, 'agent', 'a comment', strftime('%s','now'))", (first_task,)),
|
|
|
+ ("insert into task_blocks (task_id, question, created_at)"
|
|
|
+ " values (?, 'asked?', strftime('%s','now'))", (first_task,)),
|
|
|
+ ("insert into task_rules (rule_id, task_id, created_at)"
|
|
|
+ " values ((select id from rules where name = 'doomed-rule'), ?,"
|
|
|
+ " strftime('%s','now'))", (first_task,)),
|
|
|
+ ("insert into task_capabilities (capability_id, task_id)"
|
|
|
+ " values ((select id from capabilities where name = 'doomed-cap'), ?)",
|
|
|
+ (first_task,)),
|
|
|
+ ("insert into saddles (name, host, status, token)"
|
|
|
+ " values ('doomed-saddle', '127.0.0.1:9', 'idle', 't')",),
|
|
|
+ ("insert into sessions (task_id, saddle_id, started_at)"
|
|
|
+ " values (?, (select id from saddles where name = 'doomed-saddle'),"
|
|
|
+ " strftime('%s','now'))", (first_task,)),
|
|
|
+ )
|
|
|
+ session_id = int(sql_one("select id from sessions where task_id = ?", (first_task,)))
|
|
|
+ sql_exec(
|
|
|
+ ("insert into events (session_id, seq, kind, actor, scope_type,"
|
|
|
+ " scope_id, payload, created_at)"
|
|
|
+ " values (?, 1, 'turn_text', 'model', 'task', ?, '{}',"
|
|
|
+ " strftime('%s','now'))", (session_id, first_task)),
|
|
|
+ ("insert into events (session_id, seq, kind, actor, scope_type,"
|
|
|
+ " scope_id, payload, created_at)"
|
|
|
+ " values (null, null, 'comment', 'user', 'task', ?, '{}',"
|
|
|
+ " strftime('%s','now'))", (first_task,)),
|
|
|
+ ("insert into events (session_id, seq, kind, actor, scope_type,"
|
|
|
+ " scope_id, payload, created_at)"
|
|
|
+ " values (null, null, 'state_change', 'user', 'goal', ?, '{}',"
|
|
|
+ " strftime('%s','now'))", (doomed_goal,)),
|
|
|
+ ("insert into events (session_id, seq, kind, actor, scope_type,"
|
|
|
+ " scope_id, payload, created_at)"
|
|
|
+ " values (null, null, 'state_change', 'user', 'project', ?, '{}',"
|
|
|
+ " strftime('%s','now'))", (doomed,)),
|
|
|
+ )
|
|
|
+
|
|
|
+ # Project-owned rows: a note, an understanding revision, a rule
|
|
|
+ # attachment, the outgoing link to `keeper` and stack membership.
|
|
|
+ sql_exec(
|
|
|
+ ("insert into project_notes (project_id, source_task_id, body,"
|
|
|
+ " created_at) values (?, ?, 'note body', strftime('%s','now'))",
|
|
|
+ (doomed, first_task)),
|
|
|
+ ("insert into project_understandings (project_id, body, commit_hash,"
|
|
|
+ " task_id, created_at) values (?, 'understanding body', 'abc123', ?,"
|
|
|
+ " strftime('%s','now'))", (doomed, first_task)),
|
|
|
+ ("insert into rule_projects (rule_id, project_id, created_at)"
|
|
|
+ " values ((select id from rules where name = 'doomed-rule'), ?,"
|
|
|
+ " strftime('%s','now'))", (doomed,)),
|
|
|
+ ("insert into project_links (parent_project_id, linked_project_id,"
|
|
|
+ " relationship) values (?, ?, 'co-developed')", (doomed, keeper)),
|
|
|
+ ("insert into stacks (name, summary, created_at)"
|
|
|
+ " values ('doomed-stack', 's', strftime('%s','now'))",),
|
|
|
+ ("insert into stack_projects (stack_id, project_id, relationship,"
|
|
|
+ " created_at) values ((select id from stacks where name ="
|
|
|
+ " 'doomed-stack'), ?, 'reference', strftime('%s','now'))", (doomed,)),
|
|
|
+ )
|
|
|
+
|
|
|
+ print("== the survival law: OTHER projects' linked work is NEVER taken ==")
|
|
|
+ # `other-host` links to the doomed project (both directions of the
|
|
|
+ # pointer graph) and owns a task + goal whose rows merely NAME the
|
|
|
+ # doomed project through goal_links.
|
|
|
+ sql_exec(
|
|
|
+ ("insert into project_links (parent_project_id, linked_project_id,"
|
|
|
+ " relationship) values (?, ?, 'reference')", (other, doomed)),
|
|
|
+ ("insert into project_links (parent_project_id, linked_project_id,"
|
|
|
+ " relationship) values (?, ?, 'co-developed')", (other, doomed)),
|
|
|
+ ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
|
|
|
+ " description, status, priority, created_at)"
|
|
|
+ " values (?, 0, 'task', 'implement', 'human', 'Other project task',"
|
|
|
+ " 'x', 'done', 5, strftime('%s','now'))", (other,)),
|
|
|
+ ("insert into goals (project_id, title, brief, branch_name, status,"
|
|
|
+ " creator, created_at)"
|
|
|
+ " values (?, 'Other goal', 'b', 'goal/other', 'active', 'human',"
|
|
|
+ " strftime('%s','now'))", (other,)),
|
|
|
+ )
|
|
|
+ other_goal = int(sql_one("select id from goals where project_id = ?", (other,)))
|
|
|
+ other_task = int(sql_one(
|
|
|
+ "select id from tasks where project_id = ? and kind = 'task'", (other,)))
|
|
|
+ sql_exec(
|
|
|
+ # other's goal links TO the doomed project (goal_links naming it).
|
|
|
+ ("insert into goal_links (goal_id, project_id, relationship)"
|
|
|
+ " values (?, ?, 'co-developed')", (other_goal, doomed)),
|
|
|
+ # other's task depends on one of the doomed tasks as an
|
|
|
+ # auto-enqueue predecessor — the reference must CLEAR, not dangle.
|
|
|
+ ("update tasks set enqueue_after_task_id = ? where id = ?",
|
|
|
+ (first_task, other_task)),
|
|
|
+ # A standalone task (project 0) must never be touched.
|
|
|
+ ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
|
|
|
+ " description, status, priority, created_at)"
|
|
|
+ " values (0, 0, 'task', 'implement', 'human', 'Standalone task',"
|
|
|
+ " 'x', 'draft', 5, strftime('%s','now'))",),
|
|
|
+ # The keeper project links to the doomed project too — its link
|
|
|
+ # row must go, the project must stay.
|
|
|
+ ("insert into project_links (parent_project_id, linked_project_id,"
|
|
|
+ " relationship) values (?, ?, 'reference')", (keeper, doomed)),
|
|
|
+ # A goal on the KEEPER project linking the doomed project: the
|
|
|
+ # goal survives, only its link row goes.
|
|
|
+ ("insert into goals (project_id, title, brief, branch_name, status,"
|
|
|
+ " creator, created_at)"
|
|
|
+ " values (?, 'Keeper goal', 'b', 'goal/keeper', 'active', 'human',"
|
|
|
+ " strftime('%s','now'))", (keeper,)),
|
|
|
+ ("insert into goal_links (goal_id, project_id, relationship)"
|
|
|
+ " values ((select id from goals where project_id = ?), ?,"
|
|
|
+ " 'reference')", (keeper, doomed)),
|
|
|
+ )
|
|
|
+ standalone_task = int(sql_one(
|
|
|
+ "select id from tasks where project_id = 0 and title = 'Standalone task'"))
|
|
|
+
|
|
|
+ print("== the page: counts, admin-only control, typed-name gate ==")
|
|
|
+ state = slot_public(auth, "/projects/%d" % doomed, "project")
|
|
|
+ check(state["is_admin"] is True and state.get("delete") is not None,
|
|
|
+ "an admin's project page carries the delete action")
|
|
|
+ check(state["delete_counts"] == "1 goal, 3 tasks",
|
|
|
+ "the dialog counts what will go ('%s')" % state["delete_counts"])
|
|
|
+ check(state["active_tasks"] == 0,
|
|
|
+ "no live tasks under the doomed project (guard open)")
|
|
|
+
|
|
|
+ plain_state = slot_public(plain, "/projects/%d" % doomed, "project")
|
|
|
+ check(plain_state.get("delete") is None and plain_state.get("is_admin") is False,
|
|
|
+ "a non-admin's project page hides the delete action entirely")
|
|
|
+
|
|
|
+ # Wrong typed name refuses: nothing deleted.
|
|
|
+ result = act(auth, state["delete"],
|
|
|
+ {"project_id": str(doomed), "confirm_name": "doome"})
|
|
|
+ check("exact name" in (action_notify(result) or ""),
|
|
|
+ "a wrong typed name refuses the deletion")
|
|
|
+ check(sql_one("select count(*) from projects where id = ?", (doomed,)) == 1,
|
|
|
+ "the refused confirmation left the project in place")
|
|
|
+
|
|
|
+ print("== the guard: live tasks refuse ==")
|
|
|
+ guard_project = make_project("guarded")
|
|
|
+ sql_exec(
|
|
|
+ ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
|
|
|
+ " description, status, priority, created_at)"
|
|
|
+ " values (?, 0, 'task', 'implement', 'human', 'Working task', 'x',"
|
|
|
+ " 'working', 5, strftime('%s','now'))", (guard_project,)),
|
|
|
+ )
|
|
|
+ guard_state = slot_public(auth, "/projects/%d" % guard_project, "project")
|
|
|
+ check(guard_state["active_tasks"] == 1,
|
|
|
+ "the page counts the live task (guard visible up front)")
|
|
|
+ status, body = delete_api(auth, guard_project)
|
|
|
+ check(status == 409 and "queued, working" in body.get("error", ""),
|
|
|
+ "the API refuses a live project with 409")
|
|
|
+ result = act(auth, guard_state["delete"],
|
|
|
+ {"project_id": str(guard_project), "confirm_name": "guarded"})
|
|
|
+ notify = action_notify(result) or ""
|
|
|
+ check("still has 1 task" in notify or "still has 1 tasks" in notify,
|
|
|
+ "the action refuses too, naming the live count ('%s')" % notify)
|
|
|
+ check(sql_one("select count(*) from projects where id = ?", (guard_project,)) == 1,
|
|
|
+ "the guarded project survived")
|
|
|
+ # Drop the live task; now it goes.
|
|
|
+ sql_exec(("update tasks set status = 'done' where project_id = ?", (guard_project,)))
|
|
|
+ status, body = delete_api(auth, guard_project)
|
|
|
+ check(status == 200 and body.get("ok") is True,
|
|
|
+ "after the task settles, the same delete succeeds")
|
|
|
+
|
|
|
+ print("== the delete: owned rows gone, survivors intact ==")
|
|
|
+ status, body = delete_api(auth, doomed)
|
|
|
+ check(status == 200 and body.get("ok") is True,
|
|
|
+ "the doomed project deletes cleanly over the API")
|
|
|
+
|
|
|
+ def count(query, params=()):
|
|
|
+ return sql_one(query, params) or 0
|
|
|
+
|
|
|
+ # Gone: the project row, its tasks, its goal and every owned child.
|
|
|
+ check(count("select count(*) from projects where id = ?", (doomed,)) == 0,
|
|
|
+ "the project row is gone")
|
|
|
+ check(count("select count(*) from tasks where project_id = ?", (doomed,)) == 0,
|
|
|
+ "every owned task is gone (goal runs included)")
|
|
|
+ check(count("select count(*) from goals where project_id = ?", (doomed,)) == 0,
|
|
|
+ "every owned goal is gone")
|
|
|
+ check(count("select count(*) from task_comments where task_id in"
|
|
|
+ " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
|
|
|
+ "the owned tasks' comments are gone")
|
|
|
+ check(count("select count(*) from task_blocks where task_id in"
|
|
|
+ " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
|
|
|
+ "the owned tasks' blocks are gone")
|
|
|
+ check(count("select count(*) from task_rules where task_id in"
|
|
|
+ " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
|
|
|
+ "the owned tasks' rule attachments are gone")
|
|
|
+ check(count("select count(*) from task_capabilities where task_id in"
|
|
|
+ " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
|
|
|
+ "the owned tasks' capability requirements are gone")
|
|
|
+ check(count("select count(*) from sessions where task_id in"
|
|
|
+ " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
|
|
|
+ "the owned tasks' sessions are gone")
|
|
|
+ check(count("select count(*) from events where session_id = ?", (session_id,)) == 0,
|
|
|
+ "the session-carried events are gone")
|
|
|
+ check(count("select count(*) from events where scope_type = 'task' and"
|
|
|
+ " scope_id in (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
|
|
|
+ "the task-scoped events are gone")
|
|
|
+ check(count("select count(*) from events where scope_type = 'goal' and"
|
|
|
+ " scope_id = ?", (doomed_goal,)) == 0,
|
|
|
+ "the goal-scoped events are gone")
|
|
|
+ check(count("select count(*) from events where scope_type = 'project' and"
|
|
|
+ " scope_id = ?", (doomed,)) == 0,
|
|
|
+ "the project-scoped events are gone")
|
|
|
+ check(count("select count(*) from goal_links where goal_id = ?", (doomed_goal,)) == 0,
|
|
|
+ "the doomed goal's link rows are gone")
|
|
|
+ check(count("select count(*) from project_notes where project_id = ?", (doomed,)) == 0,
|
|
|
+ "the project's notes are gone")
|
|
|
+ check(count("select count(*) from project_understandings where project_id = ?",
|
|
|
+ (doomed,)) == 0,
|
|
|
+ "the project's understanding revisions are gone")
|
|
|
+ check(count("select count(*) from rule_projects where project_id = ?", (doomed,)) == 0,
|
|
|
+ "the project's rule attachments are gone")
|
|
|
+ check(count("select count(*) from project_links where parent_project_id = ?",
|
|
|
+ (doomed,)) == 0,
|
|
|
+ "the project's outgoing links are gone")
|
|
|
+ check(count("select count(*) from stack_projects where project_id = ?",
|
|
|
+ (doomed,)) == 0,
|
|
|
+ "the project's stack memberships are gone")
|
|
|
+
|
|
|
+ # Intact: everything NOT owned by the doomed project.
|
|
|
+ check(count("select count(*) from projects where id = ?", (other,)) == 1
|
|
|
+ and count("select count(*) from projects where id = ?", (keeper,)) == 1,
|
|
|
+ "the other projects' rows survive")
|
|
|
+ check(count("select count(*) from tasks where id = ?", (other_task,)) == 1,
|
|
|
+ "CRITICAL: another project's task that linked to the doomed project SURVIVES")
|
|
|
+ check(count("select count(*) from goals where id = ?", (other_goal,)) == 1,
|
|
|
+ "CRITICAL: another project's goal that linked to the doomed project SURVIVES")
|
|
|
+ check(count("select count(*) from tasks where id = ?", (standalone_task,)) == 1,
|
|
|
+ "standalone tasks (project 0) are untouched")
|
|
|
+ check(sql_one("select enqueue_after_task_id from tasks where id = ?",
|
|
|
+ (other_task,)) == 0,
|
|
|
+ "the survivor's enqueue-after reference to a deleted task is CLEARED (not dangling)")
|
|
|
+ check(count("select count(*) from project_links where parent_project_id = ?"
|
|
|
+ " and linked_project_id = ?", (other, doomed)) == 0,
|
|
|
+ "other project's link rows NAMING the doomed project are tidied away (pointer only)")
|
|
|
+ check(count("select count(*) from project_links where parent_project_id = ?"
|
|
|
+ " and linked_project_id = ?", (keeper, doomed)) == 0,
|
|
|
+ "the keeper's link row naming the doomed project is tidied away")
|
|
|
+ check(count("select count(*) from goal_links where project_id = ?", (doomed,)) == 0,
|
|
|
+ "goal link rows in OTHER projects naming the doomed project are tidied (pointer only)")
|
|
|
+ check(count("select count(*) from goal_links where goal_id = ?",
|
|
|
+ (other_goal,)) == 0,
|
|
|
+ "the other project's goal lost only its link row to the doomed project")
|
|
|
+ keeper_goal = int(sql_one("select id from goals where project_id = ?", (keeper,)))
|
|
|
+ check(count("select count(*) from goals where id = ?", (keeper_goal,)) == 1,
|
|
|
+ "the keeper's own goal survived the pointer tidy")
|
|
|
+ # Fleet-owned rows the cascade must never take.
|
|
|
+ check(count("select count(*) from rules where name = 'doomed-rule'") == 1,
|
|
|
+ "the rule DEFINITIONS survive (only attachments went)")
|
|
|
+ check(count("select count(*) from capabilities where name = 'doomed-cap'") == 1,
|
|
|
+ "the capability DEFINITIONS survive (only requirements went)")
|
|
|
+ check(count("select count(*) from stacks where name = 'doomed-stack'") == 1,
|
|
|
+ "the stack itself survives (only its membership row went)")
|
|
|
+ check(count("select count(*) from saddles where name = 'doomed-saddle'") == 1,
|
|
|
+ "the saddle registration survives")
|
|
|
+
|
|
|
+ print("== the page after the delete ==")
|
|
|
+ # The detail page of a deleted project reports not-found.
|
|
|
+ state = slot_public(auth, "/projects/%d" % doomed, "project")
|
|
|
+ check(state["exists"] is False,
|
|
|
+ "the deleted project's page reports not found")
|
|
|
+ names = [p["name"] for p in slot_public(auth, "/projects", "projects")["projects"]]
|
|
|
+ check("doomed" not in names and "keeper" in names,
|
|
|
+ "the projects list shows the survivors only")
|
|
|
+
|
|
|
+ print("== the UI path: the action deletes and redirects ==")
|
|
|
+ # End-to-end through the page action (the dialog's form): a fresh
|
|
|
+ # project with one goal and one task goes, the reply navigates to
|
|
|
+ # /projects with a success notify.
|
|
|
+ ui_doomed = make_project("ui-doomed")
|
|
|
+ sql_exec(
|
|
|
+ ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
|
|
|
+ " description, status, priority, created_at)"
|
|
|
+ " values (?, 0, 'task', 'implement', 'human', 'UI task', 'x',"
|
|
|
+ " 'done', 5, strftime('%s','now'))", (ui_doomed,)),
|
|
|
+ ("insert into goals (project_id, title, brief, branch_name, status,"
|
|
|
+ " creator, created_at)"
|
|
|
+ " values (?, 'UI goal', 'b', 'goal/ui', 'complete', 'human',"
|
|
|
+ " strftime('%s','now'))", (ui_doomed,)),
|
|
|
+ )
|
|
|
+ ui_state = slot_public(auth, "/projects/%d" % ui_doomed, "project")
|
|
|
+ check(ui_state["delete_counts"] == "1 goal, 1 task",
|
|
|
+ "the UI project's counts read '1 goal, 1 task' ('%s')"
|
|
|
+ % ui_state["delete_counts"])
|
|
|
+ result = act(auth, ui_state["delete"],
|
|
|
+ {"project_id": str(ui_doomed), "confirm_name": "ui-doomed"})
|
|
|
+ notify = action_notify(result) or ""
|
|
|
+ check("deleted" in notify and "untouched" in notify,
|
|
|
+ "the action reports the delete and the survival guarantee ('%s')" % notify)
|
|
|
+ check(any(d.get("type") == "navigate" and d.get("uri") == "/projects"
|
|
|
+ for d in result), "the action redirects to the projects list")
|
|
|
+ check(count("select count(*) from projects where id = ?", (ui_doomed,)) == 0
|
|
|
+ and count("select count(*) from tasks where project_id = ?", (ui_doomed,)) == 0
|
|
|
+ and count("select count(*) from goals where project_id = ?", (ui_doomed,)) == 0,
|
|
|
+ "the UI delete took the project with its task and goal")
|
|
|
+
|
|
|
+ # Idempotence of the wire: deleting the same project again is a 404.
|
|
|
+ status, body = delete_api(auth, doomed)
|
|
|
+ check(status == 404, "deleting an already-deleted project answers 404")
|
|
|
+
|
|
|
+ # Housekeeping: the leftover helper rows this bundle created.
|
|
|
+ sql_exec(
|
|
|
+ ("delete from goal_links where goal_id in (select id from goals where"
|
|
|
+ " project_id in (?, ?))", (keeper, standalone_host)),
|
|
|
+ ("delete from goals where project_id in (?, ?)", (keeper, standalone_host)),
|
|
|
+ ("delete from tasks where id = ?", (standalone_task,)),
|
|
|
+ ("delete from tasks where project_id = ?", (other,)),
|
|
|
+ ("delete from goal_links where goal_id = ?", (other_goal,)),
|
|
|
+ ("delete from goals where id = ?", (other_goal,)),
|
|
|
+ ("delete from project_links where parent_project_id in (?, ?)",
|
|
|
+ (other, keeper)),
|
|
|
+ ("delete from stack_projects where stack_id ="
|
|
|
+ " (select id from stacks where name = 'doomed-stack')",),
|
|
|
+ ("delete from stacks where name = 'doomed-stack'",),
|
|
|
+ ("delete from rules where name = 'doomed-rule'",),
|
|
|
+ ("delete from capabilities where name = 'doomed-cap'",),
|
|
|
+ ("delete from saddles where name = 'doomed-saddle'",),
|
|
|
+ ("delete from projects where id in (?, ?, ?)", (other, keeper,
|
|
|
+ standalone_host)),
|
|
|
+ )
|