This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_ledger.py
Adam Moussa c7f9c1bac2 feat(agent-team): Confluence-writer node (draft -> approve gate -> write)
Add a Confluence documentation lane to the Plane-2 pipeline, flag-gated behind
AGENT_TEAM_CONFLUENCE_ENABLED (default off; daemon behavior unchanged when off).

- confluence/client.py: OAuth 2LO + Basic REST client, dry-run-default writes
- confluence/mermaid.py: vendored ADF-only Mermaid editor (macro-count +
  revert-diff guards, dry-run default)
- nodes/confluence_writer.py(+_llm): conf_draft -> conf_gate -> conf_write,
  both direct (task_kind=confluence) and post-build documentation flows
- task_model/graph/coordinator: new phases, state channels, route_after_intake,
  CONFLUENCE_APPROVAL_KIND gate delivery, task_kind forwarding
- db schema v5: widen pending_questions kind CHECK (atomic rebuild)
- tests for client, mermaid, writer node, ledger v5, coordinator gate, e2e
2026-06-25 10:56:48 -04:00

764 lines
26 KiB
Python

"""Unit tests for agent_team.ledger — pending-questions ledger ops (§3.3.1)."""
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
import pytest
from agent_team.db.schema import SCHEMA_VERSION, connect, init_db, migrate
from agent_team.ledger import (
QUESTION_STATES,
PendingQuestion,
answer_question,
answered_questions,
count_by_status,
expire_question,
get_question,
list_questions,
open_questions_needing_ref,
overdue_open_questions,
post_question,
set_channel_ref,
supersede_question,
)
@pytest.fixture()
def conn(tmp_path: Path) -> sqlite3.Connection:
"""A connection to an initialized agent-team DB."""
db = tmp_path / "ledger.sqlite"
init_db(db)
connection = connect(db)
yield connection
connection.close()
# --------------------------------------------------------------------------
# Re-export contract: the ledger exposes the foundation primitives verbatim.
# --------------------------------------------------------------------------
def test_reexports_are_the_foundation_objects() -> None:
from agent_team.db import schema
assert answer_question is schema.answer_question
assert expire_question is schema.expire_question
assert supersede_question is schema.supersede_question
assert QUESTION_STATES is schema.QUESTION_STATES
# --------------------------------------------------------------------------
# post_question — write the row `open` first, no channel_ref (delivery step 1).
# --------------------------------------------------------------------------
def test_post_question_writes_open_row_without_ref(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="q1", thread_id="t1", turn=0, transport="slack")
q = get_question(conn, "q1")
assert q is not None
assert q.status == "open"
assert q.channel_ref is None
assert q.transport == "slack"
assert q.posted_at # defaulted to now
assert q.deadline_at is None
def test_post_question_records_deadline_and_posted_at(
conn: sqlite3.Connection,
) -> None:
post_question(
conn,
question_id="q1",
thread_id="t1",
turn=2,
transport="github",
deadline_at="2026-06-17T12:00:00+00:00",
posted_at="2026-06-17T11:00:00+00:00",
)
q = get_question(conn, "q1")
assert q is not None
assert q.turn == 2
assert q.deadline_at == "2026-06-17T12:00:00+00:00"
assert q.posted_at == "2026-06-17T11:00:00+00:00"
def test_post_question_duplicate_id_raises(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="dup", thread_id="t", turn=0, transport="slack")
with pytest.raises(sqlite3.IntegrityError):
post_question(conn, question_id="dup", thread_id="t", turn=1, transport="slack")
def test_post_question_defaults_kind_clarify(conn: sqlite3.Connection) -> None:
"""Existing call sites (no kind arg) keep writing clarifier rows."""
post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack")
q = get_question(conn, "q1")
assert q is not None
assert q.kind == "clarify"
def test_post_question_accepts_plan_decision_kind(conn: sqlite3.Connection) -> None:
"""A plan-review row round-trips with kind='plan_decision' on the read path."""
post_question(
conn,
question_id="q-pd",
thread_id="t",
turn=0,
transport="slack",
kind="plan_decision",
)
q = get_question(conn, "q-pd")
assert q is not None
assert q.kind == "plan_decision"
# --------------------------------------------------------------------------
# set_channel_ref — delivery step 2, guarded on status='open'.
# --------------------------------------------------------------------------
def test_set_channel_ref_on_open_row(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack")
assert set_channel_ref(conn, question_id="q1", channel_ref="1700.0001") is True
assert get_question(conn, "q1").channel_ref == "1700.0001"
def test_set_channel_ref_unknown_id_returns_false(conn: sqlite3.Connection) -> None:
assert set_channel_ref(conn, question_id="nope", channel_ref="x") is False
def test_set_channel_ref_refuses_non_open(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack")
answer_question(conn, question_id="q1", answer_json="{}", answered_via="slack")
# Late post-confirm must not resurrect a ref on an answered question.
assert set_channel_ref(conn, question_id="q1", channel_ref="late") is False
assert get_question(conn, "q1").channel_ref is None
# --------------------------------------------------------------------------
# Compare-and-set ops integrate with ledger-inserted rows (first-answer-wins).
# --------------------------------------------------------------------------
def test_answer_first_wins_on_posted_row(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack")
first = answer_question(
conn, question_id="q1", answer_json='{"a":1}', answered_via="slack"
)
second = answer_question(
conn, question_id="q1", answer_json='{"a":2}', answered_via="github"
)
assert (first, second) == (True, False)
q = get_question(conn, "q1")
assert q.status == "answered"
assert q.answer_json == '{"a":1}'
assert q.answered_via == "slack"
assert q.answered_at
# --------------------------------------------------------------------------
# open_questions_needing_ref — lost-post reconcile feed.
# --------------------------------------------------------------------------
def test_open_questions_needing_ref(conn: sqlite3.Connection) -> None:
post_question(
conn,
question_id="no-ref",
thread_id="t",
turn=0,
transport="slack",
posted_at="2026-06-17T01:00:00+00:00",
)
post_question(
conn,
question_id="with-ref",
thread_id="t",
turn=1,
transport="slack",
posted_at="2026-06-17T02:00:00+00:00",
)
set_channel_ref(conn, question_id="with-ref", channel_ref="ts")
# answered rows (even without a ref) are not delivery-reconcile candidates
post_question(
conn,
question_id="answered",
thread_id="t",
turn=2,
transport="slack",
posted_at="2026-06-17T03:00:00+00:00",
)
answer_question(conn, question_id="answered", answer_json="{}", answered_via="x")
ids = [q.question_id for q in open_questions_needing_ref(conn)]
assert ids == ["no-ref"]
# --------------------------------------------------------------------------
# overdue_open_questions + deadline-vs-answer race (§3.3.1).
# --------------------------------------------------------------------------
def test_overdue_open_questions_filters_by_deadline(
conn: sqlite3.Connection,
) -> None:
post_question(
conn,
question_id="past",
thread_id="t",
turn=0,
transport="slack",
deadline_at="2026-06-17T10:00:00+00:00",
)
post_question(
conn,
question_id="future",
thread_id="t",
turn=1,
transport="slack",
deadline_at="2026-06-17T20:00:00+00:00",
)
post_question(
conn,
question_id="no-deadline",
thread_id="t",
turn=2,
transport="slack",
)
overdue = overdue_open_questions(conn, now="2026-06-17T12:00:00+00:00")
assert [q.question_id for q in overdue] == ["past"]
def test_overdue_excludes_already_closed(conn: sqlite3.Connection) -> None:
post_question(
conn,
question_id="q",
thread_id="t",
turn=0,
transport="slack",
deadline_at="2026-06-17T10:00:00+00:00",
)
answer_question(conn, question_id="q", answer_json="{}", answered_via="slack")
assert overdue_open_questions(conn, now="2026-06-17T12:00:00+00:00") == []
def test_answer_after_expire_loses(conn: sqlite3.Connection) -> None:
"""Deterministic deadline-vs-answer race: expiry first, then answer loses."""
post_question(
conn,
question_id="q",
thread_id="t",
turn=0,
transport="slack",
deadline_at="2026-06-17T10:00:00+00:00",
)
(overdue,) = overdue_open_questions(conn, now="2026-06-17T12:00:00+00:00")
assert expire_question(conn, question_id=overdue.question_id) is True
assert (
answer_question(conn, question_id="q", answer_json="{}", answered_via="slack")
is False
)
assert get_question(conn, "q").status == "expired"
# --------------------------------------------------------------------------
# answered_questions — resume-worker / restart-recovery feed.
# --------------------------------------------------------------------------
def test_answered_questions_feed(conn: sqlite3.Connection) -> None:
for qid, tid in (("a", "t1"), ("b", "t2")):
post_question(conn, question_id=qid, thread_id=tid, turn=0, transport="slack")
answer_question(conn, question_id="a", answer_json="{}", answered_via="slack")
# b stays open
answered = answered_questions(conn)
assert [q.question_id for q in answered] == ["a"]
# thread scoping
assert answered_questions(conn, thread_id="t2") == []
assert [q.question_id for q in answered_questions(conn, thread_id="t1")] == ["a"]
# --------------------------------------------------------------------------
# list_questions — manual CLI feed.
# --------------------------------------------------------------------------
def test_list_questions_orders_oldest_first(conn: sqlite3.Connection) -> None:
post_question(
conn,
question_id="newer",
thread_id="t",
turn=1,
transport="slack",
posted_at="2026-06-17T05:00:00+00:00",
)
post_question(
conn,
question_id="older",
thread_id="t",
turn=0,
transport="slack",
posted_at="2026-06-17T01:00:00+00:00",
)
assert [q.question_id for q in list_questions(conn)] == ["older", "newer"]
def test_list_questions_status_filter(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="open1", thread_id="t", turn=0, transport="slack")
post_question(conn, question_id="ans1", thread_id="t", turn=1, transport="slack")
answer_question(conn, question_id="ans1", answer_json="{}", answered_via="slack")
assert [q.question_id for q in list_questions(conn, status="open")] == ["open1"]
assert [q.question_id for q in list_questions(conn, status="answered")] == ["ans1"]
def test_list_questions_thread_filter(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="a", thread_id="t1", turn=0, transport="slack")
post_question(conn, question_id="b", thread_id="t2", turn=0, transport="slack")
assert [q.question_id for q in list_questions(conn, thread_id="t1")] == ["a"]
def test_list_questions_rejects_unknown_status(conn: sqlite3.Connection) -> None:
with pytest.raises(ValueError):
list_questions(conn, status="bogus")
# --------------------------------------------------------------------------
# count_by_status — stable shape over all states.
# --------------------------------------------------------------------------
def test_count_by_status_stable_shape(conn: sqlite3.Connection) -> None:
post_question(conn, question_id="o1", thread_id="t", turn=0, transport="slack")
post_question(conn, question_id="o2", thread_id="t", turn=1, transport="slack")
post_question(conn, question_id="a1", thread_id="t", turn=2, transport="slack")
answer_question(conn, question_id="a1", answer_json="{}", answered_via="slack")
counts = count_by_status(conn)
assert set(counts) == set(QUESTION_STATES)
assert counts["open"] == 2
assert counts["answered"] == 1
assert counts["expired"] == 0
assert counts["superseded"] == 0
# --------------------------------------------------------------------------
# get_question + PendingQuestion view.
# --------------------------------------------------------------------------
def test_get_question_missing_returns_none(conn: sqlite3.Connection) -> None:
assert get_question(conn, "ghost") is None
def test_pending_question_from_row(conn: sqlite3.Connection) -> None:
post_question(
conn,
question_id="q",
thread_id="t",
turn=3,
transport="github",
deadline_at="2026-06-17T12:00:00+00:00",
)
set_channel_ref(conn, question_id="q", channel_ref="cref")
q = get_question(conn, "q")
assert isinstance(q, PendingQuestion)
assert (q.question_id, q.thread_id, q.turn, q.transport) == (
"q",
"t",
3,
"github",
)
assert q.channel_ref == "cref"
# frozen dataclass — read snapshot, not mutable.
with pytest.raises(Exception):
q.status = "answered" # type: ignore[misc]
# --------------------------------------------------------------------------
# Concurrency: two threads racing to set the channel_ref via the open guard.
# --------------------------------------------------------------------------
def test_concurrent_answer_single_winner_via_ledger(tmp_path: Path) -> None:
db = tmp_path / "race.sqlite"
init_db(db)
seed = connect(db)
try:
post_question(
seed, question_id="race", thread_id="t", turn=0, transport="slack"
)
finally:
seed.close()
results: list[bool] = []
barrier = threading.Barrier(2)
lock = threading.Lock()
def worker(via: str) -> None:
c = connect(db)
try:
barrier.wait()
won = answer_question(
c, question_id="race", answer_json='{"v":1}', answered_via=via
)
with lock:
results.append(won)
finally:
c.close()
threads = [threading.Thread(target=worker, args=(f"c{i}",)) for i in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert sorted(results) == [False, True]
# --------------------------------------------------------------------------
# v5 schema migration: WIDEN pending_questions.kind CHECK to add
# 'confluence_approval' via the 12-step table rebuild (db/schema.py).
# --------------------------------------------------------------------------
# The OLD (pre-v5) pending_questions DDL, whose kind CHECK accepts only
# ('clarify', 'plan_decision'). Used to build a v4-stamped DB so the v4 -> v5
# upgrade can be exercised against a realistic starting point.
_PENDING_QUESTIONS_DDL_V4 = """
CREATE TABLE pending_questions (
question_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
turn INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('open', 'answered', 'expired', 'superseded')),
transport TEXT NOT NULL,
channel_ref TEXT,
posted_at TEXT,
deadline_at TEXT,
answer_json TEXT,
answered_at TEXT,
answered_via TEXT,
kind TEXT NOT NULL DEFAULT 'clarify'
CHECK (kind IN ('clarify', 'plan_decision'))
)
""".strip()
_PENDING_QUESTIONS_INDEXES_V4 = [
"CREATE INDEX idx_pending_questions_thread ON pending_questions (thread_id, turn)",
"CREATE INDEX idx_pending_questions_status ON pending_questions (status)",
"CREATE UNIQUE INDEX uq_pending_questions_open_channel_ref "
"ON pending_questions (channel_ref) "
"WHERE channel_ref IS NOT NULL AND status = 'open'",
]
def _build_v4_db(db: Path) -> None:
"""Create a DB stamped at schema_version=4 with the OLD kind CHECK.
Mirrors the production-shaped v4 state: pending_questions carrying only
('clarify', 'plan_decision') in its kind CHECK, all three pending_questions
indexes present, and the schema_meta version row pinned at 4.
"""
conn = connect(db)
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_meta ("
"id INTEGER PRIMARY KEY CHECK (id = 1), "
"schema_version INTEGER NOT NULL)"
)
conn.execute(_PENDING_QUESTIONS_DDL_V4)
for stmt in _PENDING_QUESTIONS_INDEXES_V4:
conn.execute(stmt)
conn.execute("INSERT INTO schema_meta (id, schema_version) VALUES (1, 4)")
finally:
conn.close()
def _kind_check_accepts(conn: sqlite3.Connection, kind: str) -> bool:
"""True if inserting a pending_questions row with ``kind`` is permitted.
Probes the live CHECK constraint by attempting an insert and rolling it
back; an IntegrityError means the CHECK rejected the value.
"""
try:
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES (?, 't', 0, 'open', 'slack', ?)",
(f"probe-{kind}", kind),
)
except sqlite3.IntegrityError:
return False
conn.execute(
"DELETE FROM pending_questions WHERE question_id = ?", (f"probe-{kind}",)
)
return True
def _schema_version(conn: sqlite3.Connection) -> int:
row = conn.execute("SELECT schema_version FROM schema_meta WHERE id = 1").fetchone()
return int(row["schema_version"])
def _index_names(conn: sqlite3.Connection) -> set[str]:
rows = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type = 'index' AND tbl_name = 'pending_questions'"
).fetchall()
return {row["name"] for row in rows}
def test_fresh_init_db_stamps_v5_and_accepts_confluence_approval(
tmp_path: Path,
) -> None:
"""A fresh init_db lands at SCHEMA_VERSION and accepts 'confluence_approval'."""
db = tmp_path / "fresh.sqlite"
init_db(db)
conn = connect(db)
try:
assert SCHEMA_VERSION == 5
assert _schema_version(conn) == 5
assert _kind_check_accepts(conn, "confluence_approval") is True
assert _kind_check_accepts(conn, "clarify") is True
assert _kind_check_accepts(conn, "plan_decision") is True
assert _kind_check_accepts(conn, "nope") is False
finally:
conn.close()
def test_v4_to_v5_preserves_rows_and_widens_kind_check(tmp_path: Path) -> None:
"""v4 -> v5: existing rows survive verbatim; widened CHECK takes effect."""
db = tmp_path / "upgrade.sqlite"
_build_v4_db(db)
# Seed pre-existing v4 rows: a clarify and a plan_decision, with full
# column coverage so the rebuild's `INSERT ... SELECT *` copy is exercised.
seed = connect(db)
try:
assert _schema_version(seed) == 4
seed.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref, "
"posted_at, deadline_at, kind) "
"VALUES ('q-clar', 't1', 0, 'open', 'slack', 'ts-1', "
"'2026-06-17T01:00:00+00:00', '2026-06-17T02:00:00+00:00', 'clarify')"
)
seed.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, answer_json, "
"answered_at, answered_via, kind) "
"VALUES ('q-plan', 't2', 1, 'answered', 'github', '{\"d\":\"go\"}', "
"'2026-06-17T03:00:00+00:00', 'github', 'plan_decision')"
)
# Pre-rebuild the OLD CHECK must reject confluence_approval.
assert _kind_check_accepts(seed, "confluence_approval") is False
finally:
seed.close()
# Run the migration.
mig = connect(db)
try:
migrate(mig)
finally:
mig.close()
conn = connect(db)
try:
assert _schema_version(conn) == 5
# Existing rows preserved intact (every column round-trips).
clar = conn.execute(
"SELECT * FROM pending_questions WHERE question_id = 'q-clar'"
).fetchone()
assert clar is not None
assert clar["thread_id"] == "t1"
assert clar["turn"] == 0
assert clar["status"] == "open"
assert clar["transport"] == "slack"
assert clar["channel_ref"] == "ts-1"
assert clar["posted_at"] == "2026-06-17T01:00:00+00:00"
assert clar["deadline_at"] == "2026-06-17T02:00:00+00:00"
assert clar["kind"] == "clarify"
plan = conn.execute(
"SELECT * FROM pending_questions WHERE question_id = 'q-plan'"
).fetchone()
assert plan is not None
assert plan["status"] == "answered"
assert plan["transport"] == "github"
assert plan["answer_json"] == '{"d":"go"}'
assert plan["answered_at"] == "2026-06-17T03:00:00+00:00"
assert plan["answered_via"] == "github"
assert plan["kind"] == "plan_decision"
# Widened CHECK: confluence_approval now accepted, garbage still rejected.
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES ('q-conf', 't3', 0, 'open', 'slack', 'confluence_approval')"
)
got = conn.execute(
"SELECT kind FROM pending_questions WHERE question_id = 'q-conf'"
).fetchone()
assert got["kind"] == "confluence_approval"
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES ('q-bad', 't4', 0, 'open', 'slack', 'nope')"
)
finally:
conn.close()
def test_v4_to_v5_rebuild_preserves_partial_unique_channel_ref_index(
tmp_path: Path,
) -> None:
"""The partial unique index survives the rebuild and still enforces."""
db = tmp_path / "uq.sqlite"
_build_v4_db(db)
mig = connect(db)
try:
migrate(mig)
finally:
mig.close()
conn = connect(db)
try:
# Index exists after the rebuild.
assert "uq_pending_questions_open_channel_ref" in _index_names(conn)
# It still enforces: two OPEN rows cannot share a non-null channel_ref.
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref) "
"VALUES ('a', 't', 0, 'open', 'slack', 'dup-ts')"
)
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref) "
"VALUES ('b', 't', 1, 'open', 'slack', 'dup-ts')"
)
finally:
conn.close()
def test_v5_rebuild_is_atomic_failure_rolls_back_to_intact_table(
tmp_path: Path,
) -> None:
"""A failure mid-rebuild must roll back: the ORIGINAL table and rows survive,
version stays 4, and a subsequent migrate() recovers cleanly (no crash-loop).
Without the explicit BEGIN IMMEDIATE wrapping the swap, connect()'s autocommit
mode commits the DROP TABLE pending_questions on its own; a failure after that
point destroys the live ledger and leaves an orphan pending_questions_new that
crash-loops the next migrate(). We inject a failure at the index-creation step
(which runs AFTER the DROP/RENAME inside the txn) and assert the rebuild was
atomic.
"""
db = tmp_path / "atomic.sqlite"
_build_v4_db(db)
seed = connect(db)
try:
seed.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref, kind) "
"VALUES ('survivor', 't1', 0, 'open', 'slack', 'ts-keep', 'clarify')"
)
finally:
seed.close()
# Patch the index splitter the v5 block calls AFTER the DROP/RENAME so the
# rebuild fails mid-flight, exercising the rollback path.
import agent_team.db.schema as schema_mod
original_split = schema_mod._split_statements
def _boom(ddl: str) -> list[str]:
if "idx_pending_questions" in ddl:
raise RuntimeError("injected mid-rebuild crash")
return original_split(ddl)
mig = connect(db)
try:
schema_mod._split_statements = _boom
try:
with pytest.raises(RuntimeError, match="injected mid-rebuild crash"):
migrate(mig)
finally:
schema_mod._split_statements = original_split
finally:
mig.close()
# Rollback held: original table intact, the row survives, version still 4,
# and NO orphan pending_questions_new remains to crash the retry.
conn = connect(db)
try:
assert _schema_version(conn) == 4
tables = {
row["name"]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
assert "pending_questions" in tables
assert "pending_questions_new" not in tables
row = conn.execute(
"SELECT thread_id, channel_ref, kind FROM pending_questions "
"WHERE question_id = 'survivor'"
).fetchone()
assert row is not None
assert row["thread_id"] == "t1"
assert row["channel_ref"] == "ts-keep"
assert row["kind"] == "clarify"
# Pre-rebuild CHECK still in force: the widening did not partially apply.
assert _kind_check_accepts(conn, "confluence_approval") is False
finally:
conn.close()
# Retry recovers cleanly to v5 with the row still present.
retry = connect(db)
try:
migrate(retry)
assert _schema_version(retry) == 5
assert _kind_check_accepts(retry, "confluence_approval") is True
row = retry.execute(
"SELECT kind FROM pending_questions WHERE question_id = 'survivor'"
).fetchone()
assert row["kind"] == "clarify"
finally:
retry.close()
def test_v5_migrate_is_idempotent(tmp_path: Path) -> None:
"""Re-running migrate on a v5 DB is a no-op that keeps rows and version."""
db = tmp_path / "idem.sqlite"
_build_v4_db(db)
first = connect(db)
try:
migrate(first)
first.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES ('keep', 't', 0, 'open', 'slack', 'confluence_approval')"
)
finally:
first.close()
again = connect(db)
try:
migrate(again)
assert _schema_version(again) == 5
row = again.execute(
"SELECT kind FROM pending_questions WHERE question_id = 'keep'"
).fetchone()
assert row["kind"] == "confluence_approval"
finally:
again.close()