feat: v0.6.0 — collapse policy, session limiter, Claude Code provider, prune, browser auth, media downloads
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for browser cookie extraction (browser_cookie3 mocked)."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.browser_tokens import (
|
||||
BrowserTokenError,
|
||||
extract_chatgpt_tokens,
|
||||
extract_claude_session,
|
||||
)
|
||||
|
||||
|
||||
def _fake_bc3(monkeypatch, cookies_by_domain, raises=None):
|
||||
"""Install a stub browser_cookie3 module whose ``brave`` loader returns
|
||||
SimpleNamespace cookies for the requested domain (or raises)."""
|
||||
module = types.ModuleType("browser_cookie3")
|
||||
|
||||
def brave(domain_name=""):
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return [
|
||||
SimpleNamespace(name=name, value=value)
|
||||
for name, value in cookies_by_domain.get(domain_name, {}).items()
|
||||
]
|
||||
|
||||
module.brave = brave
|
||||
monkeypatch.setitem(sys.modules, "browser_cookie3", module)
|
||||
return module
|
||||
|
||||
|
||||
class TestExtractChatGPT:
|
||||
def test_chunked_cookies(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {
|
||||
"__Secure-next-auth.session-token.0": "eyJpart0",
|
||||
"__Secure-next-auth.session-token.1": "part1rest",
|
||||
},
|
||||
})
|
||||
assert extract_chatgpt_tokens("brave") == ("eyJpart0", "part1rest")
|
||||
|
||||
def test_single_cookie_fallback(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {"__Secure-next-auth.session-token": "eyJsingle"},
|
||||
})
|
||||
assert extract_chatgpt_tokens("brave") == ("eyJsingle", None)
|
||||
|
||||
def test_missing_cookie_raises_with_login_hint(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {"chatgpt.com": {"unrelated": "x"}})
|
||||
with pytest.raises(BrowserTokenError, match="log in"):
|
||||
extract_chatgpt_tokens("brave")
|
||||
|
||||
def test_loader_failure_wrapped(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {}, raises=RuntimeError("database is locked"))
|
||||
with pytest.raises(BrowserTokenError, match="Close the browser"):
|
||||
extract_chatgpt_tokens("brave")
|
||||
|
||||
def test_unsupported_browser_rejected(self):
|
||||
with pytest.raises(BrowserTokenError, match="Unsupported browser"):
|
||||
extract_chatgpt_tokens("netscape")
|
||||
|
||||
|
||||
class TestExtractClaude:
|
||||
def test_session_key(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {"claude.ai": {"sessionKey": "sk-ant-sid01-xyz"}})
|
||||
assert extract_claude_session("brave") == "sk-ant-sid01-xyz"
|
||||
|
||||
def test_missing_raises(self, monkeypatch):
|
||||
_fake_bc3(monkeypatch, {"claude.ai": {}})
|
||||
with pytest.raises(BrowserTokenError, match="log in"):
|
||||
extract_claude_session("brave")
|
||||
|
||||
|
||||
class TestAuthFromBrowserCLI:
|
||||
def test_writes_env_after_validation(self, monkeypatch, tmp_path):
|
||||
"""--from-browser extracts, validates live (mocked), and writes .env."""
|
||||
from click.testing import CliRunner
|
||||
from src.main import cli
|
||||
from src.cache import Cache
|
||||
|
||||
cache = Cache(tmp_path / "cache")
|
||||
cache.acknowledge_tos()
|
||||
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {
|
||||
"__Secure-next-auth.session-token.0": "eyJtok0",
|
||||
"__Secure-next-auth.session-token.1": "tok1",
|
||||
},
|
||||
"claude.ai": {"sessionKey": "sk-ant-sid01-abc"},
|
||||
})
|
||||
|
||||
# Stub out the live validation calls.
|
||||
import src.providers.chatgpt as chatgpt_mod
|
||||
import src.providers.claude as claude_mod
|
||||
monkeypatch.setattr(
|
||||
chatgpt_mod.ChatGPTProvider, "_fetch_access_token", lambda self: "at"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
claude_mod.ClaudeProvider,
|
||||
"list_conversations",
|
||||
lambda self, offset=0, limit=100: [],
|
||||
)
|
||||
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
with runner.isolated_filesystem(temp_dir=tmp_path):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "auth", "--from-browser"],
|
||||
env={"CACHE_DIR": str(tmp_path / "cache")},
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
from pathlib import Path
|
||||
env_text = Path(".env").read_text()
|
||||
|
||||
assert "CHATGPT_SESSION_TOKEN=eyJtok0" in env_text
|
||||
assert "CHATGPT_SESSION_TOKEN_1=tok1" in env_text
|
||||
assert "CLAUDE_SESSION_KEY=sk-ant-sid01-abc" in env_text
|
||||
assert "2 provider(s) configured" in result.output
|
||||
|
||||
def test_validation_failure_leaves_env_untouched(self, monkeypatch, tmp_path):
|
||||
from click.testing import CliRunner
|
||||
from src.main import cli
|
||||
from src.cache import Cache
|
||||
from src.providers.base import ProviderError
|
||||
|
||||
cache = Cache(tmp_path / "cache")
|
||||
cache.acknowledge_tos()
|
||||
|
||||
_fake_bc3(monkeypatch, {
|
||||
"chatgpt.com": {"__Secure-next-auth.session-token.0": "eyJstale"},
|
||||
"claude.ai": {},
|
||||
})
|
||||
|
||||
import src.providers.chatgpt as chatgpt_mod
|
||||
|
||||
def _fail(self):
|
||||
raise ProviderError("chatgpt", "auth", RuntimeError("401"))
|
||||
|
||||
monkeypatch.setattr(chatgpt_mod.ChatGPTProvider, "_fetch_access_token", _fail)
|
||||
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
with runner.isolated_filesystem(temp_dir=tmp_path):
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "auth", "--from-browser", "brave"],
|
||||
env={"CACHE_DIR": str(tmp_path / "cache")},
|
||||
)
|
||||
import pathlib
|
||||
env_exists = pathlib.Path(".env").exists()
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "failed live validation" in result.output
|
||||
assert not env_exists
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Unit tests for the Claude Code session provider."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.blocks import (
|
||||
BLOCK_TYPE_COLLAPSED,
|
||||
BLOCK_TYPE_TEXT,
|
||||
BLOCK_TYPE_THINKING,
|
||||
BLOCK_TYPE_TOOL_RESULT,
|
||||
BLOCK_TYPE_TOOL_USE,
|
||||
render_blocks_to_markdown,
|
||||
)
|
||||
from src.loss_report import LossReport
|
||||
from src.providers.claude_code import ClaudeCodeProvider
|
||||
|
||||
|
||||
def _write_session(tmp_path, records, project_dir="-home-jesse-myproj", name="abc-123"):
|
||||
proj = tmp_path / project_dir
|
||||
proj.mkdir(parents=True, exist_ok=True)
|
||||
f = proj / f"{name}.jsonl"
|
||||
f.write_text("\n".join(json.dumps(r) for r in records), encoding="utf-8")
|
||||
return f
|
||||
|
||||
|
||||
def _records():
|
||||
"""A representative session: title, harness noise, dialogue, tool traffic."""
|
||||
return [
|
||||
{"type": "ai-title", "aiTitle": "First title", "sessionId": "abc-123"},
|
||||
{
|
||||
"type": "user",
|
||||
"cwd": "/home/jesse/myproj",
|
||||
"timestamp": "2026-05-01T10:00:00.000Z",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": (
|
||||
"<local-command-caveat>Caveat: ignore</local-command-caveat>"
|
||||
"<command-name>/model</command-name>"
|
||||
"<local-command-stdout>Set model</local-command-stdout>"
|
||||
"Please review my project."
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"timestamp": "2026-05-01T10:00:05.000Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me think about this."},
|
||||
{"type": "text", "text": "I'll review it now."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "/x"}},
|
||||
{"type": "tool_use", "id": "t2", "name": "Read", "input": {"file_path": "/y"}},
|
||||
{"type": "tool_use", "id": "t3", "name": "Bash", "input": {"command": "ls"}},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"timestamp": "2026-05-01T10:00:08.000Z",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "file contents " * 50},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"timestamp": "2026-05-01T10:00:20.000Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Here is my review: all good."}],
|
||||
},
|
||||
},
|
||||
# Harness records — skipped
|
||||
{"type": "file-history-snapshot", "snapshot": {"big": "blob"}},
|
||||
{"type": "permission-mode", "mode": "plan"},
|
||||
# Subagent and meta records — skipped
|
||||
{
|
||||
"type": "assistant",
|
||||
"isSidechain": True,
|
||||
"message": {"role": "assistant", "content": [{"type": "text", "text": "subagent noise"}]},
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"isMeta": True,
|
||||
"message": {"role": "user", "content": "meta noise"},
|
||||
},
|
||||
# Later title wins
|
||||
{"type": "ai-title", "aiTitle": "Review my project", "sessionId": "abc-123"},
|
||||
]
|
||||
|
||||
|
||||
class TestClaudeCodeProvider:
|
||||
def _provider(self, tmp_path, policy="placeholder"):
|
||||
return ClaudeCodeProvider(projects_dir=tmp_path, hidden_content=policy)
|
||||
|
||||
def test_listing_metadata(self, tmp_path):
|
||||
_write_session(tmp_path, _records())
|
||||
p = self._provider(tmp_path)
|
||||
convs = p.fetch_all_conversations()
|
||||
assert len(convs) == 1
|
||||
c = convs[0]
|
||||
assert c["id"] == "abc-123"
|
||||
assert c["title"] == "Review my project" # last ai-title wins
|
||||
assert c["_project_name"] == "myproj" # from cwd basename
|
||||
assert c["created_at"] == "2026-05-01T10:00:00.000Z"
|
||||
assert c["updated_at"] # file mtime
|
||||
|
||||
def test_empty_files_skipped(self, tmp_path):
|
||||
proj = tmp_path / "-home-x"
|
||||
proj.mkdir()
|
||||
(proj / "empty.jsonl").write_text("")
|
||||
p = self._provider(tmp_path)
|
||||
assert p.fetch_all_conversations() == []
|
||||
|
||||
def test_normalize_prose_only_default(self, tmp_path):
|
||||
_write_session(tmp_path, _records())
|
||||
p = self._provider(tmp_path)
|
||||
p.fetch_all_conversations()
|
||||
raw = p.get_conversation("abc-123")
|
||||
result = p.normalize_conversation(raw)
|
||||
|
||||
assert result["provider"] == "claude-code"
|
||||
assert result["title"] == "Review my project"
|
||||
assert result["project"] == "myproj"
|
||||
|
||||
roles = [m["role"] for m in result["messages"]]
|
||||
assert roles == ["user", "assistant", "assistant"]
|
||||
|
||||
# Harness tags stripped, dialogue kept
|
||||
user_text = result["messages"][0]["blocks"][0]["text"]
|
||||
assert user_text == "Please review my project."
|
||||
assert "Caveat" not in user_text
|
||||
|
||||
# Tool activity collapsed into one grouped placeholder on the
|
||||
# assistant message that ran the tools (includes the tool_result
|
||||
# bytes from the following user record).
|
||||
first_assistant = result["messages"][1]
|
||||
types = [b["type"] for b in first_assistant["blocks"]]
|
||||
assert types == [BLOCK_TYPE_TEXT, BLOCK_TYPE_COLLAPSED]
|
||||
collapsed = first_assistant["blocks"][1]
|
||||
assert "3 calls" in collapsed["origin"]
|
||||
assert "Read ×2" in collapsed["origin"]
|
||||
assert "Bash ×1" in collapsed["origin"]
|
||||
assert collapsed["size_bytes"] > 500
|
||||
|
||||
# Thinking dropped without placeholder; sidechain/meta absent
|
||||
all_blocks = [b for m in result["messages"] for b in m["blocks"]]
|
||||
assert not any(b["type"] == BLOCK_TYPE_THINKING for b in all_blocks)
|
||||
assert not any(
|
||||
"subagent noise" in (b.get("text") or "") or "meta noise" in (b.get("text") or "")
|
||||
for b in all_blocks
|
||||
)
|
||||
|
||||
def test_collapsed_counted_in_loss_report(self, tmp_path):
|
||||
_write_session(tmp_path, _records())
|
||||
p = self._provider(tmp_path)
|
||||
p.fetch_all_conversations()
|
||||
report = LossReport()
|
||||
p.normalize_conversation(p.get_conversation("abc-123"), report)
|
||||
assert report.collapsed["Read"] == 2
|
||||
assert report.collapsed["Bash"] == 1
|
||||
assert report.collapsed["tool_result"] == 1
|
||||
assert report.collapsed["thinking"] == 1
|
||||
assert report.collapsed_bytes > 500
|
||||
|
||||
def test_full_policy_keeps_everything(self, tmp_path):
|
||||
_write_session(tmp_path, _records())
|
||||
p = self._provider(tmp_path, policy="full")
|
||||
p.fetch_all_conversations()
|
||||
result = p.normalize_conversation(p.get_conversation("abc-123"))
|
||||
all_blocks = [b for m in result["messages"] for b in m["blocks"]]
|
||||
types = {b["type"] for b in all_blocks}
|
||||
assert BLOCK_TYPE_THINKING in types
|
||||
assert BLOCK_TYPE_TOOL_USE in types
|
||||
assert BLOCK_TYPE_TOOL_RESULT in types
|
||||
assert BLOCK_TYPE_COLLAPSED not in types
|
||||
|
||||
def test_updated_at_consistent_with_listing(self, tmp_path):
|
||||
"""Listing and normalized updated_at must match or the cache would
|
||||
consider every session stale on every run (mtime vs last timestamp)."""
|
||||
_write_session(tmp_path, _records())
|
||||
p = self._provider(tmp_path)
|
||||
listing = p.fetch_all_conversations()[0]
|
||||
normalized = p.normalize_conversation(p.get_conversation("abc-123"))
|
||||
assert normalized["updated_at"] == listing["updated_at"]
|
||||
|
||||
def test_title_falls_back_to_first_prompt(self, tmp_path):
|
||||
records = [r for r in _records() if r.get("type") != "ai-title"]
|
||||
_write_session(tmp_path, records)
|
||||
p = self._provider(tmp_path)
|
||||
p.fetch_all_conversations()
|
||||
result = p.normalize_conversation(p.get_conversation("abc-123"))
|
||||
assert result["title"] == "Please review my project."
|
||||
|
||||
def test_unknown_block_type_surfaces(self, tmp_path):
|
||||
records = [
|
||||
{
|
||||
"type": "assistant",
|
||||
"timestamp": "2026-05-01T10:00:00.000Z",
|
||||
"cwd": "/home/jesse/myproj",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "future_block_xyz", "data": 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
_write_session(tmp_path, records)
|
||||
p = self._provider(tmp_path)
|
||||
p.fetch_all_conversations()
|
||||
report = LossReport()
|
||||
result = p.normalize_conversation(p.get_conversation("abc-123"), report)
|
||||
assert report.unknown_blocks["claude-code.future_block_xyz"] == 1
|
||||
rendered = render_blocks_to_markdown(result["messages"][0]["blocks"])
|
||||
assert "Unsupported content" in rendered
|
||||
|
||||
def test_collapsed_placeholder_renders_grouped_line(self, tmp_path):
|
||||
_write_session(tmp_path, _records())
|
||||
p = self._provider(tmp_path)
|
||||
p.fetch_all_conversations()
|
||||
result = p.normalize_conversation(p.get_conversation("abc-123"))
|
||||
rendered = render_blocks_to_markdown(result["messages"][1]["blocks"])
|
||||
assert "> 🔧 **Tool output** — `3 calls: Read ×2, Bash ×1`" in rendered
|
||||
assert "omitted (EXPORTER_HIDDEN_CONTENT=full to keep)" in rendered
|
||||
+103
-2
@@ -124,10 +124,30 @@ class TestExportSinceValidation:
|
||||
"CHATGPT_SESSION_TOKEN": "eyJtesttoken",
|
||||
"CACHE_DIR": str(tmp_path),
|
||||
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||
# This test hits the real (failing) auth endpoint with retries;
|
||||
# don't add politeness pacing on top of the backoff sleeps.
|
||||
"REQUEST_DELAY": "0",
|
||||
},
|
||||
)
|
||||
assert "Invalid --since date" not in result.output
|
||||
|
||||
def test_max_conversations_zero_rejected(self, tmp_path):
|
||||
"""--max-conversations uses IntRange(min=1); 0 must be rejected by click."""
|
||||
self._pre_populated_cache(tmp_path)
|
||||
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "export", "--max-conversations", "0"],
|
||||
env={
|
||||
"CHATGPT_SESSION_TOKEN": "eyJtesttoken",
|
||||
"CACHE_DIR": str(tmp_path),
|
||||
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||
},
|
||||
)
|
||||
assert result.exit_code == 2
|
||||
assert "max-conversations" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LossReport summary
|
||||
@@ -145,8 +165,9 @@ class TestLossReportSummary:
|
||||
assert "[export] Run summary:" in out
|
||||
assert "conversations: 0" in out
|
||||
assert "messages rendered: 0" in out
|
||||
# Both "(none)" sentinels present — never empty parens
|
||||
assert out.count("(none)") == 2
|
||||
# All three "(none)" sentinels present — never empty parens
|
||||
# (unknown blocks, extraction failures, collapsed by policy)
|
||||
assert out.count("(none)") == 3
|
||||
|
||||
def test_top_5_breakdown(self):
|
||||
from src.loss_report import LossReport
|
||||
@@ -174,3 +195,83 @@ class TestLossReportSummary:
|
||||
out = report.format_summary()
|
||||
assert "conversations: 1" in out
|
||||
assert "messages rendered: 2" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prune command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrune:
|
||||
"""prune deletes export files not referenced by the manifest."""
|
||||
|
||||
def _setup(self, tmp_path):
|
||||
"""Cache with one referenced file; one stale file + empty-dir candidate."""
|
||||
cache = Cache(tmp_path / "cache")
|
||||
cache.acknowledge_tos()
|
||||
export_dir = tmp_path / "exports"
|
||||
keep = export_dir / "chatgpt" / "proj.2026" / "keep.md"
|
||||
keep.parent.mkdir(parents=True)
|
||||
keep.write_text("kept")
|
||||
stale = export_dir / "chatgpt" / "proj" / "2026" / "old-layout.md"
|
||||
stale.parent.mkdir(parents=True)
|
||||
stale.write_text("stale")
|
||||
cache.mark_exported("chatgpt", "conv-1", {"file_path": str(keep)})
|
||||
return export_dir, keep, stale
|
||||
|
||||
def _invoke(self, tmp_path, *args):
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
return runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "prune", *args],
|
||||
env={
|
||||
"CACHE_DIR": str(tmp_path / "cache"),
|
||||
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||
},
|
||||
)
|
||||
|
||||
def test_dry_run_lists_but_keeps_files(self, tmp_path):
|
||||
export_dir, keep, stale = self._setup(tmp_path)
|
||||
result = self._invoke(tmp_path, "--dry-run")
|
||||
assert result.exit_code == 0
|
||||
assert "old-layout.md" in result.output
|
||||
assert "Dry run" in result.output
|
||||
assert stale.exists() and keep.exists()
|
||||
|
||||
def test_yes_deletes_stale_keeps_referenced_sweeps_dirs(self, tmp_path):
|
||||
export_dir, keep, stale = self._setup(tmp_path)
|
||||
result = self._invoke(tmp_path, "--yes")
|
||||
assert result.exit_code == 0
|
||||
assert not stale.exists()
|
||||
assert keep.exists()
|
||||
# Old-layout dirs are now empty and swept
|
||||
assert not (export_dir / "chatgpt" / "proj").exists()
|
||||
|
||||
def test_refuses_with_empty_manifest(self, tmp_path):
|
||||
"""Footgun guard: after cache --clear, prune must not wipe the archive."""
|
||||
cache = Cache(tmp_path / "cache")
|
||||
cache.acknowledge_tos()
|
||||
export_dir = tmp_path / "exports"
|
||||
f = export_dir / "chatgpt" / "a.md"
|
||||
f.parent.mkdir(parents=True)
|
||||
f.write_text("data")
|
||||
result = self._invoke(tmp_path, "--yes")
|
||||
assert result.exit_code == 1
|
||||
assert "Refusing to prune" in result.output
|
||||
assert f.exists()
|
||||
|
||||
def test_aborts_without_confirmation(self, tmp_path):
|
||||
export_dir, keep, stale = self._setup(tmp_path)
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "prune"],
|
||||
input="n\n",
|
||||
env={
|
||||
"CACHE_DIR": str(tmp_path / "cache"),
|
||||
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||
},
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Aborted" in result.output
|
||||
assert stale.exists()
|
||||
|
||||
@@ -54,3 +54,65 @@ class TestValidateChatGPTToken:
|
||||
result = _validate_chatgpt_token("notajwttoken")
|
||||
assert any("does not look like a JWT" in r.message for r in caplog.records)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSessionLimiterConfig:
|
||||
"""MAX_CONVERSATIONS_PER_RUN and REQUEST_DELAY parsing in load_config."""
|
||||
|
||||
def _load(self, monkeypatch, tmp_path, **env):
|
||||
from src.config import load_config
|
||||
monkeypatch.setenv("EXPORT_DIR", str(tmp_path / "exports"))
|
||||
monkeypatch.setenv("CACHE_DIR", str(tmp_path / "cache"))
|
||||
for key in (
|
||||
"MAX_CONVERSATIONS_PER_RUN",
|
||||
"REQUEST_DELAY",
|
||||
"EXPORTER_HIDDEN_CONTENT",
|
||||
"EXPORTER_DOWNLOAD_MEDIA",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, value in env.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
return load_config()
|
||||
|
||||
def test_defaults(self, monkeypatch, tmp_path):
|
||||
cfg = self._load(monkeypatch, tmp_path)
|
||||
assert cfg.max_conversations is None
|
||||
assert cfg.request_delay == 1.0
|
||||
assert cfg.hidden_content == "placeholder"
|
||||
assert cfg.download_media == "images"
|
||||
|
||||
def test_download_media_valid(self, monkeypatch, tmp_path):
|
||||
cfg = self._load(monkeypatch, tmp_path, EXPORTER_DOWNLOAD_MEDIA="all")
|
||||
assert cfg.download_media == "all"
|
||||
|
||||
def test_download_media_invalid_raises(self, monkeypatch, tmp_path):
|
||||
from src.config import ConfigError
|
||||
with pytest.raises(ConfigError, match="EXPORTER_DOWNLOAD_MEDIA"):
|
||||
self._load(monkeypatch, tmp_path, EXPORTER_DOWNLOAD_MEDIA="sometimes")
|
||||
|
||||
def test_valid_values(self, monkeypatch, tmp_path):
|
||||
cfg = self._load(
|
||||
monkeypatch, tmp_path,
|
||||
MAX_CONVERSATIONS_PER_RUN="25", REQUEST_DELAY="0.5",
|
||||
)
|
||||
assert cfg.max_conversations == 25
|
||||
assert cfg.request_delay == 0.5
|
||||
|
||||
def test_zero_delay_allowed(self, monkeypatch, tmp_path):
|
||||
cfg = self._load(monkeypatch, tmp_path, REQUEST_DELAY="0")
|
||||
assert cfg.request_delay == 0.0
|
||||
|
||||
def test_non_integer_cap_raises(self, monkeypatch, tmp_path):
|
||||
from src.config import ConfigError
|
||||
with pytest.raises(ConfigError, match="MAX_CONVERSATIONS_PER_RUN"):
|
||||
self._load(monkeypatch, tmp_path, MAX_CONVERSATIONS_PER_RUN="lots")
|
||||
|
||||
def test_zero_cap_raises(self, monkeypatch, tmp_path):
|
||||
from src.config import ConfigError
|
||||
with pytest.raises(ConfigError, match="at least 1"):
|
||||
self._load(monkeypatch, tmp_path, MAX_CONVERSATIONS_PER_RUN="0")
|
||||
|
||||
def test_negative_delay_raises(self, monkeypatch, tmp_path):
|
||||
from src.config import ConfigError
|
||||
with pytest.raises(ConfigError, match="REQUEST_DELAY"):
|
||||
self._load(monkeypatch, tmp_path, REQUEST_DELAY="-1")
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Tests for media downloads and Joplin resource rewriting."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.blocks import (
|
||||
make_file_placeholder,
|
||||
make_image_placeholder,
|
||||
render_blocks_to_markdown,
|
||||
)
|
||||
from src.loss_report import LossReport
|
||||
from src.media import resolve_media, resolve_media_policy
|
||||
from src.providers.base import ProviderError
|
||||
from src.providers.chatgpt import parse_asset_file_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Asset reference parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseAssetFileId:
|
||||
def test_plain_sediment(self):
|
||||
assert parse_asset_file_id("sediment://file_00000000245c71fda5") == "file_00000000245c71fda5"
|
||||
|
||||
def test_generated_image_with_hash_and_page(self):
|
||||
ref = "sediment://8456107fc383a53#file_00000000979c71f685#p_6.png"
|
||||
assert parse_asset_file_id(ref) == "file_00000000979c71f685"
|
||||
|
||||
def test_file_service_scheme(self):
|
||||
assert parse_asset_file_id("file-service://file-AbCdEf") == "file-AbCdEf"
|
||||
|
||||
def test_unrecognised(self):
|
||||
assert parse_asset_file_id("https://example.com/x.png") is None
|
||||
assert parse_asset_file_id("") is None
|
||||
assert parse_asset_file_id(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Media policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMediaPolicy:
|
||||
def test_default(self, monkeypatch):
|
||||
monkeypatch.delenv("EXPORTER_DOWNLOAD_MEDIA", raising=False)
|
||||
assert resolve_media_policy() == "images"
|
||||
|
||||
def test_valid(self, monkeypatch):
|
||||
monkeypatch.setenv("EXPORTER_DOWNLOAD_MEDIA", "all")
|
||||
assert resolve_media_policy() == "all"
|
||||
|
||||
def test_invalid_falls_back(self, monkeypatch, caplog):
|
||||
monkeypatch.setenv("EXPORTER_DOWNLOAD_MEDIA", "bogus")
|
||||
assert resolve_media_policy() == "images"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_media
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
"""Minimal provider exposing download_asset + the ref parser."""
|
||||
|
||||
def __init__(self, assets=None, fail_refs=None):
|
||||
self._assets = assets or {}
|
||||
self._fail_refs = fail_refs or {}
|
||||
self.calls = []
|
||||
|
||||
def parse_asset_file_id(self, ref):
|
||||
return parse_asset_file_id(ref)
|
||||
|
||||
def download_asset(self, ref):
|
||||
self.calls.append(ref)
|
||||
if ref in self._fail_refs:
|
||||
raise ProviderError("chatgpt", "download_asset", self._fail_refs[ref])
|
||||
return self._assets[ref] # (content, mime, file_name)
|
||||
|
||||
|
||||
def _conv_with(blocks):
|
||||
return {
|
||||
"id": "conv-1",
|
||||
"title": "Has Media",
|
||||
"provider": "chatgpt",
|
||||
"project": None,
|
||||
"created_at": "2026-05-20T00:00:00+00:00",
|
||||
"messages": [{"role": "user", "blocks": blocks}],
|
||||
}
|
||||
|
||||
|
||||
class TestResolveMedia:
|
||||
def test_downloads_image_and_inlines(self, tmp_path):
|
||||
ref = "sediment://file_img1"
|
||||
provider = _FakeProvider({ref: (b"\x89PNG\r\n", "image/png", "x.png")})
|
||||
block = make_image_placeholder(ref=ref, source="user_upload")
|
||||
conv = _conv_with([block])
|
||||
report = LossReport()
|
||||
|
||||
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report)
|
||||
|
||||
assert n == 1
|
||||
assert report.media_downloaded == 1
|
||||
assert block["local_path"] == "media/file_img1.png"
|
||||
rendered = render_blocks_to_markdown([block])
|
||||
assert rendered == ""
|
||||
# File written under the conversation's media/ dir
|
||||
written = list(tmp_path.rglob("media/file_img1.png"))
|
||||
assert written and written[0].read_bytes() == b"\x89PNG\r\n"
|
||||
|
||||
def test_images_policy_skips_files(self, tmp_path):
|
||||
ref = "sediment://file_audio1"
|
||||
provider = _FakeProvider({ref: (b"RIFF", "audio/wav", "a.wav")})
|
||||
block = make_file_placeholder(ref=ref, mime="audio/wav", size_bytes=1000)
|
||||
conv = _conv_with([block])
|
||||
report = LossReport()
|
||||
|
||||
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report)
|
||||
assert n == 0
|
||||
assert "local_path" not in block
|
||||
assert provider.calls == []
|
||||
|
||||
def test_all_policy_downloads_files(self, tmp_path):
|
||||
ref = "sediment://file_audio1"
|
||||
provider = _FakeProvider({ref: (b"RIFFdata", "audio/wav", "a.wav")})
|
||||
block = make_file_placeholder(ref=ref, mime="audio/wav", size_bytes=8)
|
||||
conv = _conv_with([block])
|
||||
report = LossReport()
|
||||
|
||||
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "all", report)
|
||||
assert n == 1
|
||||
assert block["local_path"] == "media/file_audio1.wav"
|
||||
rendered = render_blocks_to_markdown([block])
|
||||
assert "media/file_audio1.wav" in rendered and rendered.startswith("> 📎")
|
||||
|
||||
def test_off_policy_noop(self, tmp_path):
|
||||
provider = _FakeProvider({"sediment://file_x": (b"x", "image/png", None)})
|
||||
block = make_image_placeholder(ref="sediment://file_x", source="user_upload")
|
||||
conv = _conv_with([block])
|
||||
report = LossReport()
|
||||
assert resolve_media(conv, provider, tmp_path, "provider/project/year", "off", report) == 0
|
||||
assert provider.calls == []
|
||||
|
||||
def test_idempotent_uses_disk(self, tmp_path):
|
||||
ref = "sediment://file_img1"
|
||||
provider = _FakeProvider({ref: (b"\x89PNG", "image/png", "x.png")})
|
||||
conv = _conv_with([make_image_placeholder(ref=ref, source="user_upload")])
|
||||
report = LossReport()
|
||||
resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report)
|
||||
assert len(provider.calls) == 1
|
||||
|
||||
# Second run, fresh blocks: file already on disk → no new API call.
|
||||
conv2 = _conv_with([make_image_placeholder(ref=ref, source="user_upload")])
|
||||
resolve_media(conv2, provider, tmp_path, "provider/project/year", "images", LossReport())
|
||||
assert len(provider.calls) == 1 # unchanged
|
||||
assert conv2["messages"][0]["blocks"][0]["local_path"] == "media/file_img1.png"
|
||||
|
||||
def test_failure_keeps_placeholder_and_counts(self, tmp_path):
|
||||
ref = "sediment://file_gone"
|
||||
provider = _FakeProvider(fail_refs={ref: RuntimeError("Signed URL returned HTTP 404")})
|
||||
block = make_image_placeholder(ref=ref, source="model_generated")
|
||||
conv = _conv_with([block])
|
||||
report = LossReport()
|
||||
|
||||
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report)
|
||||
assert n == 0
|
||||
assert "local_path" not in block
|
||||
assert report.media_failed["expired-or-missing"] == 1
|
||||
# Still renders as a placeholder, not a broken image link
|
||||
assert render_blocks_to_markdown([block]).startswith("> 🖼️")
|
||||
|
||||
def test_provider_without_download_asset(self, tmp_path):
|
||||
"""claude-code has no remote assets — resolve_media must no-op."""
|
||||
class NoDownload:
|
||||
pass
|
||||
block = make_image_placeholder(ref="sediment://file_x", source="user_upload")
|
||||
conv = _conv_with([block])
|
||||
assert resolve_media(conv, NoDownload(), tmp_path, "provider/project/year", "images", LossReport()) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Joplin media rewriting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeJoplin:
|
||||
def __init__(self):
|
||||
self.uploaded = []
|
||||
self._n = 0
|
||||
|
||||
def create_resource(self, file_path, title=None):
|
||||
self.uploaded.append(Path(file_path).name)
|
||||
self._n += 1
|
||||
return f"res{self._n}"
|
||||
|
||||
|
||||
class TestUploadMediaAndRewrite:
|
||||
def _note(self, tmp_path, body):
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(media / "file_img1.png").write_bytes(b"\x89PNG")
|
||||
(media / "clip.wav").write_bytes(b"RIFF")
|
||||
return body
|
||||
|
||||
def test_rewrites_image_and_file_links(self, tmp_path):
|
||||
from src.joplin import upload_media_and_rewrite
|
||||
body = self._note(
|
||||
tmp_path,
|
||||
"Look: \n"
|
||||
"> 📎 **File attached** — [clip.wav](media/clip.wav) (audio/wav)",
|
||||
)
|
||||
client = _FakeJoplin()
|
||||
new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {})
|
||||
|
||||
assert "" in new_body
|
||||
assert "(:/res2)" in new_body
|
||||
assert "media/" not in new_body
|
||||
assert res_map == {"media/file_img1.png": "res1", "media/clip.wav": "res2"}
|
||||
assert len(client.uploaded) == 2
|
||||
|
||||
def test_reuses_known_resource_ids(self, tmp_path):
|
||||
from src.joplin import upload_media_and_rewrite
|
||||
body = self._note(tmp_path, "")
|
||||
client = _FakeJoplin()
|
||||
existing = {"media/file_img1.png": "existing-res"}
|
||||
new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, existing)
|
||||
assert "(:/existing-res)" in new_body
|
||||
assert client.uploaded == [] # no re-upload
|
||||
assert res_map == existing
|
||||
|
||||
def test_missing_file_left_as_link(self, tmp_path):
|
||||
from src.joplin import upload_media_and_rewrite
|
||||
(tmp_path / "media").mkdir()
|
||||
body = ""
|
||||
client = _FakeJoplin()
|
||||
new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {})
|
||||
assert new_body == body
|
||||
assert res_map == {}
|
||||
assert client.uploaded == []
|
||||
|
||||
def test_no_media_links_untouched(self, tmp_path):
|
||||
from src.joplin import upload_media_and_rewrite
|
||||
body = "Just text with [a link](https://example.com) and `media/foo` in code."
|
||||
client = _FakeJoplin()
|
||||
new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {})
|
||||
assert new_body == body
|
||||
assert res_map == {}
|
||||
@@ -171,6 +171,7 @@ class TestChatGPTNormalization:
|
||||
def test_user_editable_context_emits_blocks(self):
|
||||
raw = json.loads((FIXTURES / "chatgpt_conversation.json").read_text())
|
||||
p = self._get_provider()
|
||||
p._hidden_content = "full"
|
||||
result = p.normalize_conversation(raw)
|
||||
|
||||
# The user_editable_context message has user_profile + user_instructions.
|
||||
@@ -194,6 +195,7 @@ class TestChatGPTNormalization:
|
||||
|
||||
raw = json.loads((FIXTURES / "chatgpt_conversation.json").read_text())
|
||||
p = self._get_provider()
|
||||
p._hidden_content = "full"
|
||||
result = p.normalize_conversation(raw)
|
||||
|
||||
uec_msgs = [
|
||||
@@ -290,6 +292,236 @@ class TestChatGPTUnknownContent:
|
||||
assert report.unknown_blocks["future_unknown_type_xyz"] == 1
|
||||
|
||||
|
||||
class TestChatGPTHiddenContentPolicy:
|
||||
"""EXPORTER_HIDDEN_CONTENT: collapse retrieval dumps and hidden context.
|
||||
|
||||
file_search dumps re-inject attached-file text on every tool run (measured
|
||||
86% of content bytes on real data) and are NOT hidden-flagged — author.name
|
||||
is the discriminator. Hidden-flagged messages (Custom Instructions) are the
|
||||
secondary case.
|
||||
"""
|
||||
|
||||
def _get_provider(self, policy=None):
|
||||
from src.providers.chatgpt import ChatGPTProvider
|
||||
p = ChatGPTProvider.__new__(ChatGPTProvider)
|
||||
import requests
|
||||
p._session = requests.Session()
|
||||
p._org_id = None
|
||||
p._project_ids = []
|
||||
p._project_map = {}
|
||||
p._project_name_cache = {}
|
||||
if policy is not None:
|
||||
p._hidden_content = policy
|
||||
return p
|
||||
|
||||
def _make_conv(self):
|
||||
return {
|
||||
"id": "test-collapse",
|
||||
"title": "T",
|
||||
"create_time": 1700000000.0,
|
||||
"update_time": 1700000001.0,
|
||||
"mapping": {
|
||||
"root": {"id": "root", "message": None, "parent": None, "children": ["dump"]},
|
||||
"dump": {
|
||||
"id": "dump",
|
||||
"parent": "root",
|
||||
"children": ["answer"],
|
||||
"message": {
|
||||
"id": "dump",
|
||||
"author": {"role": "tool", "name": "file_search"},
|
||||
"content": {
|
||||
"content_type": "multimodal_text",
|
||||
"parts": ["Full text of an attached file " * 100],
|
||||
},
|
||||
},
|
||||
},
|
||||
"answer": {
|
||||
"id": "answer",
|
||||
"parent": "dump",
|
||||
"children": ["uec"],
|
||||
"message": {
|
||||
"id": "answer",
|
||||
"author": {"role": "assistant"},
|
||||
"content": {"content_type": "text", "parts": ["The answer."]},
|
||||
},
|
||||
},
|
||||
"uec": {
|
||||
"id": "uec",
|
||||
"parent": "answer",
|
||||
"children": [],
|
||||
"message": {
|
||||
"id": "uec",
|
||||
"author": {"role": "user"},
|
||||
"content": {
|
||||
"content_type": "user_editable_context",
|
||||
"user_profile": "Preferred name: Jesse",
|
||||
"user_instructions": "Be concise.",
|
||||
},
|
||||
"metadata": {"is_visually_hidden_from_conversation": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def test_default_policy_is_placeholder(self, monkeypatch):
|
||||
from src.blocks import BLOCK_TYPE_COLLAPSED
|
||||
monkeypatch.delenv("EXPORTER_HIDDEN_CONTENT", raising=False)
|
||||
# No _hidden_content attribute → env fallback → placeholder default.
|
||||
result = self._get_provider().normalize_conversation(self._make_conv())
|
||||
collapsed = [
|
||||
b for m in result["messages"] for b in m["blocks"]
|
||||
if b.get("type") == BLOCK_TYPE_COLLAPSED
|
||||
]
|
||||
assert len(collapsed) == 2
|
||||
|
||||
def test_placeholder_collapses_file_search_dump(self):
|
||||
from src.blocks import BLOCK_TYPE_COLLAPSED, COLLAPSED_KIND_TOOL_DUMP
|
||||
result = self._get_provider("placeholder").normalize_conversation(self._make_conv())
|
||||
tool_msgs = [m for m in result["messages"] if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
(block,) = tool_msgs[0]["blocks"]
|
||||
assert block["type"] == BLOCK_TYPE_COLLAPSED
|
||||
assert block["kind"] == COLLAPSED_KIND_TOOL_DUMP
|
||||
assert block["origin"] == "file_search"
|
||||
assert block["size_bytes"] > 1000
|
||||
|
||||
def test_placeholder_collapses_hidden_flagged_message(self):
|
||||
from src.blocks import BLOCK_TYPE_COLLAPSED, COLLAPSED_KIND_HIDDEN_CONTEXT
|
||||
result = self._get_provider("placeholder").normalize_conversation(self._make_conv())
|
||||
user_msgs = [m for m in result["messages"] if m["role"] == "user"]
|
||||
assert len(user_msgs) == 1
|
||||
(block,) = user_msgs[0]["blocks"]
|
||||
assert block["type"] == BLOCK_TYPE_COLLAPSED
|
||||
assert block["kind"] == COLLAPSED_KIND_HIDDEN_CONTEXT
|
||||
assert block["origin"] == "user_editable_context"
|
||||
|
||||
def test_placeholder_keeps_normal_messages(self):
|
||||
result = self._get_provider("placeholder").normalize_conversation(self._make_conv())
|
||||
assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
|
||||
assert assistant_msgs[0]["blocks"][0]["type"] == BLOCK_TYPE_TEXT
|
||||
assert assistant_msgs[0]["blocks"][0]["text"] == "The answer."
|
||||
|
||||
def test_non_retrieval_tool_authors_not_collapsed(self):
|
||||
"""web.run / browser / python tool messages are real content — keep."""
|
||||
from src.blocks import BLOCK_TYPE_COLLAPSED
|
||||
conv = self._make_conv()
|
||||
conv["mapping"]["dump"]["message"]["author"]["name"] = "web.run"
|
||||
conv["mapping"]["dump"]["message"]["content"] = {
|
||||
"content_type": "text",
|
||||
"parts": ["Search results summary."],
|
||||
}
|
||||
result = self._get_provider("placeholder").normalize_conversation(conv)
|
||||
tool_msgs = [m for m in result["messages"] if m["role"] == "tool"]
|
||||
assert tool_msgs[0]["blocks"][0]["type"] != BLOCK_TYPE_COLLAPSED
|
||||
|
||||
def test_omit_drops_messages_entirely(self):
|
||||
result = self._get_provider("omit").normalize_conversation(self._make_conv())
|
||||
assert [m["role"] for m in result["messages"]] == ["assistant"]
|
||||
|
||||
def test_full_preserves_v040_behavior(self):
|
||||
result = self._get_provider("full").normalize_conversation(self._make_conv())
|
||||
tool_msgs = [m for m in result["messages"] if m["role"] == "tool"]
|
||||
assert tool_msgs[0]["blocks"][0]["type"] == BLOCK_TYPE_TEXT
|
||||
user_msgs = [m for m in result["messages"] if m["role"] == "user"]
|
||||
assert user_msgs[0]["blocks"][0]["type"] == BLOCK_TYPE_HIDDEN_CONTEXT_MARKER
|
||||
|
||||
def test_collapsed_tallied_in_loss_report(self):
|
||||
report = LossReport()
|
||||
self._get_provider("placeholder").normalize_conversation(self._make_conv(), report)
|
||||
assert report.collapsed["file_search"] == 1
|
||||
assert report.collapsed["user_editable_context"] == 1
|
||||
assert report.collapsed_bytes > 1000
|
||||
summary = report.format_summary()
|
||||
assert "collapsed by policy: 2" in summary
|
||||
assert "EXPORTER_HIDDEN_CONTENT=full to keep" in summary
|
||||
|
||||
def test_collapsed_block_renders_one_line_with_size(self):
|
||||
from src.blocks import BLOCK_TYPE_COLLAPSED
|
||||
result = self._get_provider("placeholder").normalize_conversation(self._make_conv())
|
||||
tool_msgs = [m for m in result["messages"] if m["role"] == "tool"]
|
||||
rendered = render_blocks_to_markdown(tool_msgs[0]["blocks"])
|
||||
assert rendered.startswith("> 🔧 **Tool output** — `file_search` (")
|
||||
assert "KB" in rendered
|
||||
assert "omitted (EXPORTER_HIDDEN_CONTENT=full to keep)" in rendered
|
||||
assert "\n" not in rendered
|
||||
|
||||
def test_invalid_env_value_falls_back_to_placeholder(self, monkeypatch, caplog):
|
||||
from src.providers.chatgpt import resolve_hidden_content_policy
|
||||
monkeypatch.setenv("EXPORTER_HIDDEN_CONTENT", "bogus")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert resolve_hidden_content_policy() == "placeholder"
|
||||
assert any("EXPORTER_HIDDEN_CONTENT" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
class TestRequestPacing:
|
||||
"""REQUEST_DELAY politeness pacing between consecutive API requests."""
|
||||
|
||||
def _get_provider(self, delay):
|
||||
from src.providers.chatgpt import ChatGPTProvider
|
||||
p = ChatGPTProvider.__new__(ChatGPTProvider)
|
||||
p._request_delay = delay
|
||||
p._last_request_at = None
|
||||
return p
|
||||
|
||||
def test_first_request_not_delayed(self, monkeypatch):
|
||||
sleeps = []
|
||||
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: sleeps.append(s))
|
||||
p = self._get_provider(2.0)
|
||||
p._pace()
|
||||
assert sleeps == []
|
||||
|
||||
def test_consecutive_requests_paced_with_jitter_window(self, monkeypatch):
|
||||
sleeps = []
|
||||
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: sleeps.append(s))
|
||||
monkeypatch.setattr("src.providers.base.random.uniform", lambda a, b: 1.0)
|
||||
clock = {"now": 100.0}
|
||||
monkeypatch.setattr("src.providers.base.time.monotonic", lambda: clock["now"])
|
||||
p = self._get_provider(2.0)
|
||||
p._pace()
|
||||
clock["now"] = 100.5 # only 0.5s elapsed since last request
|
||||
p._pace()
|
||||
assert sleeps == [pytest.approx(1.5)]
|
||||
|
||||
def test_no_sleep_when_enough_time_elapsed(self, monkeypatch):
|
||||
sleeps = []
|
||||
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: sleeps.append(s))
|
||||
monkeypatch.setattr("src.providers.base.random.uniform", lambda a, b: 1.0)
|
||||
clock = {"now": 100.0}
|
||||
monkeypatch.setattr("src.providers.base.time.monotonic", lambda: clock["now"])
|
||||
p = self._get_provider(1.0)
|
||||
p._pace()
|
||||
clock["now"] = 105.0
|
||||
p._pace()
|
||||
assert sleeps == []
|
||||
|
||||
def test_zero_delay_disables_pacing(self, monkeypatch):
|
||||
sleeps = []
|
||||
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: sleeps.append(s))
|
||||
p = self._get_provider(0)
|
||||
p._pace()
|
||||
p._pace()
|
||||
assert sleeps == []
|
||||
|
||||
def test_pace_noop_without_init_attrs(self):
|
||||
"""Providers built via __new__ in tests have no pacing attrs — must not crash."""
|
||||
from src.providers.chatgpt import ChatGPTProvider
|
||||
p = ChatGPTProvider.__new__(ChatGPTProvider)
|
||||
p._pace() # no exception
|
||||
|
||||
def test_resolve_request_delay_default_and_overrides(self, monkeypatch, caplog):
|
||||
from src.providers.base import resolve_request_delay, DEFAULT_REQUEST_DELAY
|
||||
monkeypatch.delenv("REQUEST_DELAY", raising=False)
|
||||
assert resolve_request_delay() == DEFAULT_REQUEST_DELAY
|
||||
monkeypatch.setenv("REQUEST_DELAY", "2.5")
|
||||
assert resolve_request_delay() == 2.5
|
||||
monkeypatch.setenv("REQUEST_DELAY", "0")
|
||||
assert resolve_request_delay() == 0.0
|
||||
monkeypatch.setenv("REQUEST_DELAY", "abc")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert resolve_request_delay() == DEFAULT_REQUEST_DELAY
|
||||
assert any("REQUEST_DELAY" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claude
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user