#!/usr/bin/env python3 """Focused contract tests for the desktop AI session-draft inbox.""" import copy import contextlib import importlib.util import io import json import stat import sqlite3 import subprocess import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "tools")) from import_ai_session_draft import ImportFailure, import_payload, load # noqa: E402 from export_ai_session_drafts import build_export, mark_published # noqa: E402 _drive_spec = importlib.util.spec_from_file_location( "sync_ai_session_draft_drive", ROOT / "tools/sync_ai_session_draft_drive.py" ) drive = importlib.util.module_from_spec(_drive_spec) _drive_spec.loader.exec_module(drive) EX_REPS = "ex_11111111-1111-4111-8111-111111111111" EX_DURATION = "ex_22222222-2222-4222-8222-222222222222" EX_CONTINUOUS = "ex_33333333-3333-4333-8333-333333333333" EX_ALIAS = "ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" DRAFT_ID = "aid_44444444-4444-4444-8444-444444444444" SCHEMA = """ PRAGMA foreign_keys=ON; CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER NOT NULL DEFAULT 0); CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT); CREATE TABLE performed_sets(id INTEGER PRIMARY KEY); CREATE TABLE ai_session_drafts(id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,planned_for TEXT,session_type TEXT NOT NULL,title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending',archived_at TEXT,archive_error TEXT,published_at TEXT); CREATE TABLE ai_session_draft_entries(id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,equipment_id TEXT,load_mode TEXT NOT NULL,target_sets INTEGER NOT NULL,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,rest_seconds INTEGER NOT NULL,UNIQUE(draft_row_id,position)); CREATE TABLE ai_session_draft_imports(draft_row_id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL); CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END; PRAGMA user_version=18; """ def artifact(): return {"format": "TRAINLOG_AI_SESSION_DRAFT", "version": 1, "draft": { "draft_id": DRAFT_ID, "created_at": "2026-09-14T08:30:00Z", "planned_for": "2026-09-15", "session_type": "training", "title": "Séance IA", "notes": None, "entries": [ {"position": 0, "exercise_id": EX_ALIAS, "target_sets": 4, "target_reps": 8, "target_duration_seconds": None, "target_weight_kg": 42.5, "rest_seconds": 90}, {"position": 1, "exercise_id": EX_DURATION, "target_sets": 3, "target_reps": None, "target_duration_seconds": 45, "target_weight_kg": None, "rest_seconds": 30}, ]}} class DraftTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.database = Path(self.temp.name) / "trainlog.db" con = sqlite3.connect(self.database) con.executescript(SCHEMA) con.executemany("INSERT INTO exercises(exercise_id,recording_mode,tracking_mode,data_fields) VALUES(?,?,?,?)", [ (EX_REPS, "sets", "reps", 3), (EX_DURATION, "sets", "duration", 0), (EX_CONTINUOUS, "continuous", "duration", 0)]) con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (EX_ALIAS, EX_REPS)) con.commit() con.close() def tearDown(self): self.temp.cleanup() def connect(self): con = sqlite3.connect(self.database) con.execute("PRAGMA foreign_keys=ON") return con def test_import_alias_targets_only_idempotence_conflict_and_export_shape(self): con = self.connect() self.assertEqual("imported", import_payload(con, artifact(), "2026-09-14T09:00:00Z")) self.assertEqual("skipped", import_payload(con, artifact(), "2026-09-14T09:01:00Z")) self.assertEqual(0, con.execute("SELECT count(*) FROM performed_sets").fetchone()[0]) stored = con.execute( "SELECT de.entry_id,de.source_exercise_id,e.exercise_id,de.recording_mode,de.tracking_mode,de.load_mode,de.target_reps,de.target_duration_seconds " "FROM ai_session_draft_entries de JOIN exercises e ON e.id=de.exercise_row_id WHERE position=0" ).fetchone() self.assertRegex(stored[0], r"^sxe_[0-9a-f-]{36}$") self.assertEqual((EX_ALIAS, EX_REPS, "sets", "reps", "external", 8, None), stored[1:]) changed = artifact(); changed["draft"]["title"] = "Autre" with self.assertRaisesRegex(ImportFailure, "conflit"): import_payload(con, changed) exported = build_export(con, "2026-09-14T09:02:00Z") self.assertEqual({"format", "version", "generated_at", "drafts"}, set(exported)) self.assertEqual("trainlog-ai-session-drafts", exported["format"]) outbound = exported["drafts"][0]["entries"][0] self.assertEqual(EX_REPS, outbound["exercise_id"]) self.assertEqual(3, outbound["data_fields"]) self.assertEqual({"entry_id", "position", "exercise_id", "recording_mode", "tracking_mode", "data_fields", "equipment_id", "load_mode", "rest_seconds", "target"}, set(outbound)) self.assertEqual({"sets": 4, "reps": 8, "duration_seconds": None, "weight_kg": 42.5}, outbound["target"]) con.close() def test_existing_v18_database_gets_publication_cursor(self): con = self.connect() con.execute("ALTER TABLE ai_session_drafts DROP COLUMN published_at") con.commit() self.assertEqual([], build_export(con, "2026-09-14T09:02:00Z")["drafts"]) self.assertIn("published_at", {row[1] for row in con.execute( "PRAGMA table_info(ai_session_drafts)")}) con.close() def test_target_sets_99_and_bounded_publication_lifecycle(self): value = artifact() value["draft"]["entries"][0]["target_sets"] = 99 con = self.connect() self.assertEqual("imported", import_payload(con, value)) self.assertEqual(99, con.execute( "SELECT target_sets FROM ai_session_draft_entries WHERE position=0" ).fetchone()[0]) base = con.execute( "SELECT created_at,planned_for,session_type,title,notes FROM ai_session_drafts WHERE draft_id=?", (DRAFT_ID,), ).fetchone() for index in range(1, 256): con.execute( "INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)", (f"aid_00000000-0000-4000-8000-{index:012d}",) + base, ) con.execute( "INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)", ("aid_00000000-0000-4000-8000-000000000256",) + base, ) con.commit() first = build_export(con, "2026-09-14T09:02:00Z") self.assertEqual(256, len(first["drafts"])) # Failed publication performs no mark: deterministic retry is identical. self.assertEqual([d["draft_id"] for d in first["drafts"]], [d["draft_id"] for d in build_export(con, "2026-09-14T09:03:00Z")["drafts"]]) self.assertEqual(256, mark_published(con, first, "2026-09-14T09:04:00Z")) second = build_export(con, "2026-09-14T09:05:00Z") self.assertEqual(1, len(second["drafts"])) self.assertEqual(DRAFT_ID, second["drafts"][0]["draft_id"]) self.assertEqual(1, mark_published(con, second, "2026-09-14T09:06:00Z")) self.assertEqual([], build_export(con, "2026-09-14T09:07:00Z")["drafts"]) self.assertEqual(1, con.execute("SELECT count(*) FROM ai_session_draft_imports").fetchone()[0]) con.close() def test_strict_failures(self): mutations = [] value = artifact(); value["extra"] = 1; mutations.append(value) value = artifact(); value["draft"]["created_at"] = "2026-09-14T08:30:00+00:00"; mutations.append(value) value = artifact(); value["draft"]["entries"][1]["position"] = 2; mutations.append(value) value = artifact(); value["draft"]["entries"][0]["target_duration_seconds"] = 10; mutations.append(value) value = artifact(); value["draft"]["entries"][0]["target_weight_kg"] = float("inf"); mutations.append(value) value = artifact(); value["draft"]["entries"][0]["notes"] = None; mutations.append(value) value = artifact(); value["draft"]["entries"][0]["exercise_id"] = EX_CONTINUOUS; value["draft"]["entries"][0]["target_reps"] = None; value["draft"]["entries"][0]["target_duration_seconds"] = 20; mutations.append(value) con = self.connect() for value in mutations: with self.assertRaises(ImportFailure): import_payload(con, value) self.assertEqual(0, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0]) con.close() duplicate = Path(self.temp.name) / "duplicate.json" duplicate.write_text('{"format":"TRAINLOG_AI_SESSION_DRAFT","format":"x","version":1,"draft":{}}', encoding="utf-8") with self.assertRaisesRegex(ImportFailure, "dupliquée"): load(duplicate) def test_forced_child_failure_rolls_back_every_row(self): con = self.connect() con.execute("CREATE TRIGGER reject_second BEFORE INSERT ON ai_session_draft_entries WHEN NEW.position=1 BEGIN SELECT RAISE(ABORT,'forced'); END") with self.assertRaises(sqlite3.Error): import_payload(con, artifact()) self.assertEqual((0, 0, 0), ( con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0], con.execute("SELECT count(*) FROM ai_session_draft_entries").fetchone()[0], con.execute("SELECT count(*) FROM ai_session_draft_imports").fetchone()[0])) con.close() def test_fake_rclone_listing_failure_and_archive_failure_are_nonfatal(self): missing_calls = [] def missing_runner(argv, **kwargs): missing_calls.append(argv) return subprocess.CompletedProcess(argv, 4, "", "not found") self.assertEqual(0, drive.synchronize(self.database, missing_runner)) self.assertEqual("rclone", missing_calls[0][0]) source = artifact(); calls = [] def archive_fails(argv, **kwargs): calls.append(argv) if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") if argv[1] == "copyto" and argv[2] == drive.SOURCE: Path(argv[3]).write_text(json.dumps(source), encoding="utf-8") return subprocess.CompletedProcess(argv, 0, "", "") return subprocess.CompletedProcess(argv, 9, "", "offline") self.assertEqual(0, drive.synchronize(self.database, archive_fails)) con = self.connect() self.assertEqual(("failed", "offline"), con.execute( "SELECT archive_status,archive_error FROM ai_session_drafts WHERE draft_id=?", (DRAFT_ID,)).fetchone()) self.assertEqual(1, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0]) con.close() self.assertEqual(["rclone", "lsf"], calls[0][:2]) self.assertEqual(["rclone", "copyto"], calls[1][:2]) self.assertEqual(["rclone", "copyto"], calls[2][:2]) self.assertNotEqual(drive.SOURCE, calls[2][2]) self.assertTrue(calls[2][2].endswith("/draft.json")) self.assertEqual(f"{drive.ARCHIVE_ROOT}/{DRAFT_ID}.json", calls[2][3]) def test_inbound_destination_is_private_during_and_after_copy(self): source = artifact() observed = [] def successful_runner(argv, **kwargs): if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") if argv[2] == drive.SOURCE: destination = Path(argv[3]) self.assertTrue(destination.exists()) self.assertEqual(0o600, stat.S_IMODE(destination.stat().st_mode)) destination.write_text(json.dumps(source), encoding="utf-8") observed.append(stat.S_IMODE(destination.stat().st_mode)) return subprocess.CompletedProcess(argv, 0, "", "") self.assertEqual(0, drive.synchronize(self.database, successful_runner)) self.assertEqual([0o600], observed) def test_source_replacement_after_fetch_archives_imported_bytes_without_removing_replacement(self): original = artifact() replacement = artifact() replacement["draft"]["draft_id"] = "aid_55555555-5555-4555-8555-555555555555" remote = {drive.SOURCE: json.dumps(original)} archived = {} def replacing_runner(argv, **kwargs): if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") if argv[2] == drive.SOURCE: Path(argv[3]).write_text(remote[drive.SOURCE], encoding="utf-8") remote[drive.SOURCE] = json.dumps(replacement) else: archived[argv[3]] = Path(argv[2]).read_text(encoding="utf-8") return subprocess.CompletedProcess(argv, 0, "", "") self.assertEqual(0, drive.synchronize(self.database, replacing_runner)) target = f"{drive.ARCHIVE_ROOT}/{DRAFT_ID}.json" self.assertEqual(original, json.loads(archived[target])) self.assertEqual(replacement, json.loads(remote[drive.SOURCE])) def test_archive_status_update_failure_converges_on_replay(self): source = artifact() archive_copies = [] def successful_runner(argv, **kwargs): if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") if argv[2] == drive.SOURCE: Path(argv[3]).write_text(json.dumps(source), encoding="utf-8") else: archive_copies.append(Path(argv[2]).read_text(encoding="utf-8")) return subprocess.CompletedProcess(argv, 0, "", "") original_update = drive.try_update_archive updates = 0 def fail_once(*args, **kwargs): nonlocal updates updates += 1 if updates == 1: return "synthetic update failure" return original_update(*args, **kwargs) drive.try_update_archive = fail_once try: first = io.StringIO() with contextlib.redirect_stdout(first): self.assertEqual(0, drive.synchronize(self.database, successful_runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL", first.getvalue()) second = io.StringIO() with contextlib.redirect_stdout(second): self.assertEqual(0, drive.synchronize(self.database, successful_runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", second.getvalue()) finally: drive.try_update_archive = original_update self.assertEqual(2, len(archive_copies)) con = self.connect() self.assertEqual("archived", con.execute( "SELECT archive_status FROM ai_session_drafts WHERE draft_id=?", (DRAFT_ID,) ).fetchone()[0]) con.close() def test_helper_reports_imported_then_already_imported(self): source = artifact() def successful_runner(argv, **kwargs): if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") if argv[1] == "copyto" and argv[2] == drive.SOURCE: Path(argv[3]).write_text(json.dumps(source), encoding="utf-8") return subprocess.CompletedProcess(argv, 0, "", "") first = io.StringIO() with contextlib.redirect_stdout(first): self.assertEqual(0, drive.synchronize(self.database, successful_runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=IMPORTED", first.getvalue()) second = io.StringIO() with contextlib.redirect_stdout(second): self.assertEqual(0, drive.synchronize(self.database, successful_runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", second.getvalue()) def test_listing_distinguishes_empty_and_transport_failure(self): def empty_runner(argv, **kwargs): self.assertEqual("lsf", argv[1]) self.assertEqual(drive.INBOX_ROOT, argv[2]) self.assertEqual(["--files-only", "--max-depth", "1", "--include", drive.ARTIFACT_NAME], argv[3:]) return subprocess.CompletedProcess(argv, 0, "other.json\n", "") out = io.StringIO() with contextlib.redirect_stdout(out): self.assertEqual(0, drive.synchronize(self.database, empty_runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=NONE", out.getvalue()) def unavailable_runner(argv, **kwargs): raise OSError("authentication unavailable") out = io.StringIO() with contextlib.redirect_stdout(out): self.assertEqual(0, drive.synchronize(self.database, unavailable_runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue()) def test_present_file_copy_failure_or_timeout_is_drive_failure(self): def runner(argv, **kwargs): if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") raise subprocess.TimeoutExpired(argv, drive.RCLONE_TIMEOUT_SECONDS) out = io.StringIO() with contextlib.redirect_stdout(out): self.assertEqual(0, drive.synchronize(self.database, runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue()) self.assertNotIn("AI_SESSION_DRAFT_INBOUND=NONE", out.getvalue()) def test_disappearing_source_and_unwritten_temp_are_drive_failures(self): stale = Path(self.temp.name) / "draft.json" stale.write_text(json.dumps(artifact()), encoding="utf-8") for returncode in (7, 0): def runner(argv, **kwargs): if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") return subprocess.CompletedProcess(argv, returncode, "", "source disappeared") out = io.StringIO() with contextlib.redirect_stdout(out): self.assertEqual(0, drive.synchronize(self.database, runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue()) con = self.connect() self.assertEqual(0, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0]) con.close() def test_present_invalid_json_is_rejected(self): def runner(argv, **kwargs): if argv[1] == "lsf": return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "") if argv[1] == "copyto": Path(argv[3]).write_text("{invalid", encoding="utf-8") return subprocess.CompletedProcess(argv, 0, "", "") out = io.StringIO() with contextlib.redirect_stdout(out): self.assertEqual(0, drive.synchronize(self.database, runner)) self.assertIn("AI_SESSION_DRAFT_INBOUND=REJECTED", out.getvalue()) if __name__ == "__main__": unittest.main()