From f353d90ec66bdee87f1181f1c0f8d58533fec038 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:07:07 +0400 Subject: [PATCH] =?UTF-8?q?feat(hub):=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=D1=81=D0=BE=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D1=8F=D1=8E=D1=82=D1=81=D1=8F=20=D0=BA=20?= =?UTF-8?q?=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B5=D0=B9,=20db.utcnow=20?= =?UTF-8?q?=D0=B8=20db.parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/db.py | 17 ++++++++++++++++- tests/test_db.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 81a2556..a3b712d 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -128,7 +128,12 @@ def migrate(conn: sqlite3.Connection) -> None: @contextmanager def transaction(conn: sqlite3.Connection): - """BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт).""" + """BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт). + Вложенный вызов (транзакция уже открыта) входит во внешнюю: своих BEGIN/COMMIT/ROLLBACK не делает, + исключение уходит внешнему блоку, который откатит всё.""" + if conn.in_transaction: + yield conn + return conn.execute("BEGIN IMMEDIATE") try: yield conn @@ -137,3 +142,13 @@ def transaction(conn: sqlite3.Connection): raise else: conn.execute("COMMIT") + + +def utcnow() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def parse(ts: str) -> datetime: + """ISO-8601 из базы → aware datetime (naive считаем UTC).""" + d = datetime.fromisoformat(ts) + return d if d.tzinfo else d.replace(tzinfo=timezone.utc) diff --git a/tests/test_db.py b/tests/test_db.py index 36a0a02..a402771 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -25,3 +25,34 @@ def test_transaction_rolls_back_on_error(tmp_path): except RuntimeError: pass assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_nested_transaction_joins_outer(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + try: + with db.transaction(conn): + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')") + assert conn.in_transaction # вложенный блок не закоммитил сам + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 1 + raise RuntimeError("boom") + except RuntimeError: + pass + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную + + +def test_nested_transaction_error_propagates_to_outer(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + try: + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')") + with db.transaction(conn): + raise RuntimeError("inner") + except RuntimeError: + pass + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_parse_and_utcnow(): + assert db.parse("2020-01-01T00:00:00").tzinfo is not None + assert db.parse(db.now()) <= db.utcnow()