401 lines
15 KiB
Python
401 lines
15 KiB
Python
"""Regression tests for #5379: gate 'Cron Jobs' project auto-creation behind
|
|
project opt-in.
|
|
|
|
ensure_cron_project() used to unconditionally mint and persist a 'Cron Jobs'
|
|
project the first time any code path saw a cron session, even on installs
|
|
that never created a project of their own. These tests pin the fix: creation
|
|
is now gated on whether the active profile already has at least one real
|
|
(non-system) project; lookup, renamed-root alias resolution, and legacy
|
|
untagged back-tagging are all unaffected. ensure_cron_project() itself is
|
|
never mocked here — only PROJECTS_FILE and profile-resolution helpers are.
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
|
|
def _make_cron_state_db(path, *, cron_count=1):
|
|
"""state.db with a batch of cron-sourced sessions (mirrors #4842's helper)."""
|
|
conn = sqlite3.connect(str(path))
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT,
|
|
session_source TEXT,
|
|
title TEXT,
|
|
model TEXT,
|
|
started_at REAL NOT NULL,
|
|
message_count INTEGER DEFAULT 0,
|
|
parent_session_id TEXT,
|
|
ended_at REAL,
|
|
end_reason TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp REAL
|
|
);
|
|
"""
|
|
)
|
|
now = time.time()
|
|
for i in range(cron_count):
|
|
sid = f"cron_job{i:04d}_{int(now) + i}"
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, session_source, title, model,"
|
|
" started_at, message_count, parent_session_id, ended_at, end_reason)"
|
|
" VALUES (?, 'cron', 'cron', 'Cron run', 'test-model', ?, 1, NULL, NULL, NULL)",
|
|
(sid, now + i),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (id, session_id, role, content, timestamp)"
|
|
" VALUES (?, ?, 'user', 'cron task', ?)",
|
|
(f"cron_msg_{i:04d}", sid, now + i),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _write_projects(path, projects):
|
|
path.write_text(json.dumps(projects), encoding="utf-8")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_projects(tmp_path, monkeypatch):
|
|
"""Point PROJECTS_FILE at a fresh tmp_path file; reset profile-alias caches."""
|
|
import api.config as cfg
|
|
import api.models as models
|
|
import api.profiles as profiles
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
monkeypatch.setattr(cfg, "PROJECTS_FILE", projects_file)
|
|
monkeypatch.setattr(models, "PROJECTS_FILE", projects_file)
|
|
monkeypatch.setattr(models, "_projects_migrated", True)
|
|
monkeypatch.setattr(models, "_CRON_PROJECT_LOCK", threading.Lock())
|
|
monkeypatch.setattr(models, "_WEBHOOK_PROJECT_LOCK", threading.Lock())
|
|
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [])
|
|
monkeypatch.setattr(profiles, "_active_profile", "default")
|
|
profiles._invalidate_root_profile_cache()
|
|
yield projects_file
|
|
profiles._invalidate_root_profile_cache()
|
|
|
|
|
|
def test_sidebar_scan_zero_user_projects_skips_cron_project_creation(tmp_path):
|
|
"""Reproduction anchor: listing the sidebar with a cron row must not
|
|
create or persist a Cron Jobs project when no user project exists."""
|
|
import api.models as models
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
db = tmp_path / "state.db"
|
|
_make_cron_state_db(db, cron_count=1)
|
|
|
|
rows = models._load_cli_sessions_uncached(
|
|
tmp_path, db, _cli_profile=None, include_claude_code=False,
|
|
)
|
|
|
|
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
|
|
assert len(cron_rows) == 1
|
|
assert cron_rows[0]["project_id"] is None
|
|
assert not projects_file.exists(), (
|
|
"gate must not persist a Cron Jobs project when the active profile "
|
|
"has zero user-created projects"
|
|
)
|
|
|
|
|
|
def test_recovery_endpoint_zero_user_projects_skips_cron_project_creation(monkeypatch, tmp_path):
|
|
import api.routes as routes
|
|
|
|
sid = "cron_recover_0001"
|
|
cron_meta = {
|
|
"session_id": sid,
|
|
"title": "Cron run",
|
|
"model": "test-model",
|
|
"created_at": 10.0,
|
|
"updated_at": 20.0,
|
|
"source_tag": "cron",
|
|
"raw_source": "cron",
|
|
"session_source": "cron",
|
|
"source_label": "Cron",
|
|
"is_cli_session": True,
|
|
"read_only": False,
|
|
}
|
|
messages = [{"role": "user", "content": "run"}, {"role": "assistant", "content": "done"}]
|
|
|
|
class FakeSession:
|
|
def __init__(self):
|
|
self.project_id = None
|
|
|
|
def save(self, touch_updated_at=False):
|
|
pass
|
|
|
|
def compact(self):
|
|
return {"session_id": sid, "title": "Cron run"}
|
|
|
|
fake = FakeSession()
|
|
|
|
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, _sid: None))
|
|
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
|
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
|
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid, profile=None: messages)
|
|
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [cron_meta])
|
|
monkeypatch.setattr(routes, "import_cli_session", lambda *a, **k: fake)
|
|
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *a, **k: None)
|
|
monkeypatch.setattr(routes, "_queue_generated_title_for_imported_session", lambda *a, **k: None)
|
|
|
|
response = routes._handle_session_import_cli(object(), {"session_id": sid})
|
|
|
|
assert response["imported"] is True
|
|
assert fake.project_id is None
|
|
assert not (tmp_path / "projects.json").exists()
|
|
|
|
|
|
def test_sidebar_scan_with_user_project_still_autoassigns_cron(tmp_path):
|
|
import api.models as models
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
_write_projects(projects_file, [
|
|
{"project_id": "user-proj-1", "name": "My Website", "profile": "default", "created_at": 1.0},
|
|
])
|
|
db = tmp_path / "state.db"
|
|
_make_cron_state_db(db, cron_count=1)
|
|
|
|
rows = models._load_cli_sessions_uncached(
|
|
tmp_path, db, _cli_profile=None, include_claude_code=False,
|
|
)
|
|
|
|
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
|
|
assert len(cron_rows) == 1
|
|
assert cron_rows[0]["project_id"] is not None
|
|
|
|
saved = json.loads(projects_file.read_text())
|
|
cron_saved = [p for p in saved if p["name"] == "Cron Jobs"]
|
|
assert len(cron_saved) == 1
|
|
|
|
|
|
def test_recovery_endpoint_with_user_project_still_autoassigns_cron(monkeypatch, tmp_path):
|
|
import api.routes as routes
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
_write_projects(projects_file, [
|
|
{"project_id": "user-proj-1", "name": "My Website", "profile": "default", "created_at": 1.0},
|
|
])
|
|
|
|
sid = "cron_recover_0002"
|
|
cron_meta = {
|
|
"session_id": sid,
|
|
"title": "Cron run",
|
|
"model": "test-model",
|
|
"created_at": 10.0,
|
|
"updated_at": 20.0,
|
|
"source_tag": "cron",
|
|
"raw_source": "cron",
|
|
"session_source": "cron",
|
|
"source_label": "Cron",
|
|
"is_cli_session": True,
|
|
"read_only": False,
|
|
}
|
|
messages = [{"role": "user", "content": "run"}, {"role": "assistant", "content": "done"}]
|
|
|
|
class FakeSession:
|
|
def __init__(self):
|
|
self.project_id = None
|
|
|
|
def save(self, touch_updated_at=False):
|
|
pass
|
|
|
|
def compact(self):
|
|
return {"session_id": sid, "title": "Cron run"}
|
|
|
|
fake = FakeSession()
|
|
|
|
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, _sid: None))
|
|
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
|
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
|
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid, profile=None: messages)
|
|
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [cron_meta])
|
|
monkeypatch.setattr(routes, "import_cli_session", lambda *a, **k: fake)
|
|
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *a, **k: None)
|
|
monkeypatch.setattr(routes, "_queue_generated_title_for_imported_session", lambda *a, **k: None)
|
|
|
|
response = routes._handle_session_import_cli(object(), {"session_id": sid})
|
|
|
|
assert response["imported"] is True
|
|
assert fake.project_id is not None
|
|
saved = json.loads(projects_file.read_text())
|
|
assert any(p["name"] == "Cron Jobs" for p in saved)
|
|
|
|
|
|
def test_preexisting_tagged_cron_project_resolves_when_zero_user_projects(tmp_path):
|
|
import api.models as models
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
_write_projects(projects_file, [
|
|
{"project_id": "existing-cron", "name": "Cron Jobs", "profile": "default", "color": "#6366f1", "created_at": 1.0},
|
|
])
|
|
db = tmp_path / "state.db"
|
|
_make_cron_state_db(db, cron_count=1)
|
|
|
|
rows = models._load_cli_sessions_uncached(
|
|
tmp_path, db, _cli_profile=None, include_claude_code=False,
|
|
)
|
|
|
|
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
|
|
assert cron_rows[0]["project_id"] == "existing-cron"
|
|
saved = json.loads(projects_file.read_text())
|
|
assert len(saved) == 1
|
|
assert saved[0]["project_id"] == "existing-cron"
|
|
|
|
|
|
def test_legacy_untagged_cron_project_back_tagged_when_zero_user_projects(tmp_path, monkeypatch):
|
|
import api.models as models
|
|
import api.profiles as profiles
|
|
|
|
# A non-default active profile is required to exercise the back-tag loop:
|
|
# with 'default' active, an untagged row's implicit profile and the active
|
|
# profile both alias to the root profile (_is_root_profile('default') is
|
|
# unconditionally True), so the FIRST (alias-match) lookup loop would
|
|
# already resolve it without ever reaching the back-tag loop below it.
|
|
# Mirrors the active-profile choice in the proven
|
|
# test_issue1614_project_profile_filtering.py::test_ensure_cron_project_back_tags_legacy_untagged.
|
|
monkeypatch.setattr(profiles, "_active_profile", "haku")
|
|
profiles._invalidate_root_profile_cache()
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
_write_projects(projects_file, [
|
|
{"project_id": "legacy-cron", "name": "Cron Jobs", "color": "#6366f1", "created_at": 1.0},
|
|
])
|
|
db = tmp_path / "state.db"
|
|
_make_cron_state_db(db, cron_count=1)
|
|
|
|
rows = models._load_cli_sessions_uncached(
|
|
tmp_path, db, _cli_profile=None, include_claude_code=False,
|
|
)
|
|
|
|
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
|
|
assert cron_rows[0]["project_id"] == "legacy-cron"
|
|
saved = json.loads(projects_file.read_text())
|
|
assert saved[0]["profile"] == "haku", (
|
|
"legacy untagged project must still be back-tagged even when the "
|
|
"create branch is gated off"
|
|
)
|
|
|
|
|
|
def test_renamed_root_alias_cron_project_resolves_when_zero_user_projects(tmp_path, monkeypatch):
|
|
import api.models as models
|
|
import api.profiles as profiles
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
_write_projects(projects_file, [
|
|
{"project_id": "root-alias-cron", "name": "Cron Jobs", "profile": "default", "color": "#6366f1", "created_at": 1.0},
|
|
])
|
|
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [
|
|
{"name": "kinni", "is_default": True, "path": str(tmp_path)},
|
|
])
|
|
monkeypatch.setattr(profiles, "_active_profile", "kinni")
|
|
profiles._invalidate_root_profile_cache()
|
|
|
|
db = tmp_path / "state.db"
|
|
_make_cron_state_db(db, cron_count=1)
|
|
|
|
rows = models._load_cli_sessions_uncached(
|
|
tmp_path, db, _cli_profile=None, include_claude_code=False,
|
|
)
|
|
|
|
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
|
|
assert cron_rows[0]["project_id"] == "root-alias-cron"
|
|
|
|
|
|
def test_webhook_project_still_auto_created_when_zero_user_projects(tmp_path):
|
|
"""Adjacent-condition row: the ungated webhook path must still
|
|
auto-create its dedicated project even in the same zero-user-project
|
|
scan where the cron path is now suppressed."""
|
|
import api.models as models
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
db_path = tmp_path / "state.db"
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
|
|
started_at REAL, source TEXT, user_id TEXT
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL, content TEXT, timestamp REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, title, model, message_count, started_at, source, user_id)"
|
|
" VALUES ('webhook_route_1', NULL, 'test-model', 1, 20, 'webhook', 'webhook:read-later')"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp)"
|
|
" VALUES ('webhook_route_1', 'user', 'payload', 21)"
|
|
)
|
|
|
|
rows = models._load_cli_sessions_uncached(
|
|
tmp_path, db_path, "default", include_claude_code=False,
|
|
)
|
|
|
|
webhook_rows = [r for r in rows if r["source_tag"] == "webhook"]
|
|
assert len(webhook_rows) == 1
|
|
assert webhook_rows[0]["project_id"] is not None
|
|
|
|
saved = json.loads(projects_file.read_text())
|
|
assert any(p["name"] == "Webhooks" for p in saved)
|
|
|
|
|
|
def test_profile_has_user_projects_ignores_system_named_projects(tmp_path):
|
|
import api.models as models
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
|
|
_write_projects(projects_file, [])
|
|
assert models._profile_has_user_projects() is False
|
|
|
|
_write_projects(projects_file, [
|
|
{"project_id": "c1", "name": "Cron Jobs", "profile": "default", "created_at": 1.0},
|
|
{"project_id": "w1", "name": "Webhooks", "profile": "default", "created_at": 1.0},
|
|
])
|
|
assert models._profile_has_user_projects() is False, (
|
|
"system-named projects don't count as opting in"
|
|
)
|
|
|
|
_write_projects(projects_file, [
|
|
{"project_id": "a1", "name": "Cron Jobs Archive", "profile": "default", "created_at": 1.0},
|
|
])
|
|
assert models._profile_has_user_projects() is True, (
|
|
"only exact reserved names are excluded; similar names are user projects"
|
|
)
|
|
|
|
_write_projects(projects_file, [
|
|
{"project_id": "c1", "name": "Cron Jobs", "profile": "default", "created_at": 1.0},
|
|
{"project_id": "u1", "name": "My Real Project", "profile": "default", "created_at": 2.0},
|
|
])
|
|
assert models._profile_has_user_projects() is True
|
|
|
|
|
|
def test_ensure_cron_project_default_create_true_ignores_gate(tmp_path):
|
|
"""A direct call with no `create` argument must keep today's
|
|
unconditional-create behavior, even with zero user projects — only the
|
|
two gated callers opt in."""
|
|
import api.models as models
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
pid = models.ensure_cron_project()
|
|
|
|
saved = json.loads(projects_file.read_text())
|
|
assert any(p["project_id"] == pid and p["name"] == "Cron Jobs" for p in saved)
|