updated to run on Windows and add est capabilities
This commit is contained in:
129
tests/test_cli.py
Normal file
129
tests/test_cli.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""CLI-level tests using Click's CliRunner — no live API calls required."""
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from src.cache import Cache
|
||||
from src.main import _filter_by_project, cli
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _filter_by_project (T-27)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFilterByProject:
|
||||
"""Unit tests for the project filter logic used by export/list/joplin."""
|
||||
|
||||
# ChatGPT conversations use the _project_name annotation key
|
||||
def _chatgpt(self, conv_id, project_name):
|
||||
return {"id": conv_id, "_project_name": project_name}
|
||||
|
||||
# Claude conversations use the project dict key
|
||||
def _claude(self, conv_id, project_name):
|
||||
proj = {"name": project_name} if project_name else None
|
||||
return {"id": conv_id, "project": proj}
|
||||
|
||||
def test_none_filter_keeps_no_project_chatgpt(self):
|
||||
convs = [self._chatgpt("a", None), self._chatgpt("b", "Python Course")]
|
||||
result = _filter_by_project(convs, "none")
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "a"
|
||||
|
||||
def test_none_filter_keeps_no_project_claude(self):
|
||||
convs = [self._claude("a", None), self._claude("b", "Python Course")]
|
||||
result = _filter_by_project(convs, "none")
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "a"
|
||||
|
||||
def test_name_filter_case_insensitive(self):
|
||||
convs = [
|
||||
self._chatgpt("a", "Python Course"),
|
||||
self._chatgpt("b", "Java Course"),
|
||||
self._chatgpt("c", None),
|
||||
]
|
||||
result = _filter_by_project(convs, "PYTHON")
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "a"
|
||||
|
||||
def test_name_filter_substring_match(self):
|
||||
convs = [
|
||||
self._chatgpt("a", "Python Advanced Course"),
|
||||
self._chatgpt("b", "Python Basics"),
|
||||
self._chatgpt("c", "JavaScript"),
|
||||
]
|
||||
result = _filter_by_project(convs, "python")
|
||||
assert len(result) == 2
|
||||
assert {c["id"] for c in result} == {"a", "b"}
|
||||
|
||||
def test_no_matches_returns_empty(self):
|
||||
convs = [self._chatgpt("a", "Python Course"), self._chatgpt("b", None)]
|
||||
result = _filter_by_project(convs, "ruby")
|
||||
assert result == []
|
||||
|
||||
def test_none_filter_excludes_all_with_projects(self):
|
||||
convs = [self._chatgpt("a", "Project A"), self._chatgpt("b", "Project B")]
|
||||
result = _filter_by_project(convs, "none")
|
||||
assert result == []
|
||||
|
||||
def test_empty_string_project_treated_as_no_project(self):
|
||||
convs = [{"id": "a", "_project_name": ""}, {"id": "b", "_project_name": "Real"}]
|
||||
result = _filter_by_project(convs, "none")
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "a"
|
||||
|
||||
def test_claude_project_string_matched(self):
|
||||
# Claude can also have project as a plain string
|
||||
convs = [{"id": "a", "project": "python-course"}, {"id": "b", "project": None}]
|
||||
result = _filter_by_project(convs, "python")
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "a"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# export --since validation (T-25)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportSinceValidation:
|
||||
"""Test that --since with an invalid date exits cleanly with an error message."""
|
||||
|
||||
def _pre_populated_cache(self, tmp_path) -> Cache:
|
||||
"""Create a cache that passes the ToS gate and first-run doctor check."""
|
||||
cache = Cache(tmp_path)
|
||||
cache.acknowledge_tos()
|
||||
cache.mark_exported("chatgpt", "dummy-conv", {"updated_at": "2024-01-01T00:00:00Z"})
|
||||
return cache
|
||||
|
||||
def test_invalid_since_date_exits_with_error(self, tmp_path):
|
||||
self._pre_populated_cache(tmp_path)
|
||||
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "export", "--since", "notadate"],
|
||||
env={
|
||||
"CHATGPT_SESSION_TOKEN": "eyJtesttoken",
|
||||
"CACHE_DIR": str(tmp_path),
|
||||
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||
},
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid --since date" in result.output
|
||||
assert "YYYY-MM-DD" in result.output
|
||||
|
||||
def test_valid_since_date_does_not_error(self, tmp_path):
|
||||
"""A valid date should not produce the invalid-date error (may fail later on API)."""
|
||||
self._pre_populated_cache(tmp_path)
|
||||
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--no-log-file", "export", "--since", "2024-01-01"],
|
||||
env={
|
||||
"CHATGPT_SESSION_TOKEN": "eyJtesttoken",
|
||||
"CACHE_DIR": str(tmp_path),
|
||||
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||
},
|
||||
)
|
||||
assert "Invalid --since date" not in result.output
|
||||
56
tests/test_config.py
Normal file
56
tests/test_config.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for src/config.py — token validation logic (T-14)."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from src.config import _validate_chatgpt_token
|
||||
|
||||
|
||||
class TestValidateChatGPTToken:
|
||||
def test_expired_token_logs_warning(self, caplog):
|
||||
# T-14: expired JWT must produce a clear warning
|
||||
payload = {"exp": int(time.time()) - 3600} # expired 1 hour ago
|
||||
token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
with caplog.at_level(logging.WARNING, logger="src.config"):
|
||||
result = _validate_chatgpt_token(token)
|
||||
assert any("expired" in r.message.lower() for r in caplog.records)
|
||||
assert result is not None # still returns the expiry datetime
|
||||
|
||||
def test_expiring_within_24h_logs_warning(self, caplog):
|
||||
payload = {"exp": int(time.time()) + 3600} # expires in 1 hour
|
||||
token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
with caplog.at_level(logging.WARNING, logger="src.config"):
|
||||
_validate_chatgpt_token(token)
|
||||
assert any("less than 24 hours" in r.message for r in caplog.records)
|
||||
|
||||
def test_valid_token_no_expiry_warning(self, caplog):
|
||||
payload = {"exp": int(time.time()) + 86400 * 5} # valid for 5 days
|
||||
token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
with caplog.at_level(logging.WARNING, logger="src.config"):
|
||||
result = _validate_chatgpt_token(token)
|
||||
assert not any("expired" in r.message.lower() for r in caplog.records)
|
||||
assert result is not None
|
||||
|
||||
def test_token_without_exp_claim_logs_warning(self, caplog):
|
||||
payload = {"sub": "user123"} # no exp
|
||||
token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
with caplog.at_level(logging.WARNING, logger="src.config"):
|
||||
result = _validate_chatgpt_token(token)
|
||||
assert any("'exp'" in r.message or "no 'exp'" in r.message for r in caplog.records)
|
||||
assert result is None
|
||||
|
||||
def test_jwe_encrypted_token_returns_none(self, caplog):
|
||||
# JWE tokens (alg=dir) cannot be decoded client-side — this is normal for ChatGPT
|
||||
jwe_like = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.fake.token.data.here"
|
||||
with caplog.at_level(logging.DEBUG, logger="src.config"):
|
||||
result = _validate_chatgpt_token(jwe_like)
|
||||
assert result is None # cannot decode, but not an error
|
||||
|
||||
def test_non_jwt_string_logs_warning(self, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="src.config"):
|
||||
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
|
||||
@@ -199,6 +199,34 @@ class TestJSONExporter:
|
||||
assert " " in raw
|
||||
|
||||
|
||||
class TestBothFormats:
|
||||
"""T-38: Markdown and JSON exporters produce matching filenames for the same conversation."""
|
||||
|
||||
def test_both_formats_produce_files(self, tmp_path):
|
||||
md_exp = MarkdownExporter(tmp_path)
|
||||
json_exp = JSONExporter(tmp_path)
|
||||
md_path = md_exp.export(SAMPLE_CONV)
|
||||
json_path = json_exp.export(SAMPLE_CONV)
|
||||
assert md_path.exists()
|
||||
assert json_path.exists()
|
||||
|
||||
def test_both_formats_have_matching_stems(self, tmp_path):
|
||||
md_exp = MarkdownExporter(tmp_path)
|
||||
json_exp = JSONExporter(tmp_path)
|
||||
md_path = md_exp.export(SAMPLE_CONV)
|
||||
json_path = json_exp.export(SAMPLE_CONV)
|
||||
assert md_path.suffix == ".md"
|
||||
assert json_path.suffix == ".json"
|
||||
assert md_path.stem == json_path.stem
|
||||
|
||||
def test_both_formats_same_directory(self, tmp_path):
|
||||
md_exp = MarkdownExporter(tmp_path)
|
||||
json_exp = JSONExporter(tmp_path)
|
||||
md_path = md_exp.export(SAMPLE_CONV)
|
||||
json_path = json_exp.export(SAMPLE_CONV)
|
||||
assert md_path.parent == json_path.parent
|
||||
|
||||
|
||||
class TestYamlEscape:
|
||||
def test_escapes_double_quotes(self):
|
||||
assert _yaml_escape('Say "hello"') == 'Say \\"hello\\"'
|
||||
|
||||
@@ -75,6 +75,39 @@ class TestChatGPTNormalization:
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
def test_model_editable_context_included_without_warning(self, caplog):
|
||||
"""model_editable_context messages (project instructions) should be included, not warned about."""
|
||||
import logging
|
||||
conv = {
|
||||
"id": "test-conv-mec",
|
||||
"title": "Test",
|
||||
"create_time": 1700000000.0,
|
||||
"update_time": 1700000001.0,
|
||||
"mapping": {
|
||||
"root": {"id": "root", "message": None, "parent": None, "children": ["msg1"]},
|
||||
"msg1": {
|
||||
"id": "msg1",
|
||||
"message": {
|
||||
"id": "msg1",
|
||||
"author": {"role": "user"},
|
||||
"content": {
|
||||
"content_type": "model_editable_context",
|
||||
"parts": ["These are the project instructions."],
|
||||
},
|
||||
"create_time": 1700000001.0,
|
||||
"status": "finished_successfully",
|
||||
},
|
||||
"parent": "root",
|
||||
"children": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
p = self._get_provider()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = p.normalize_conversation(conv)
|
||||
assert any(m["content"] == "These are the project instructions." for m in result["messages"])
|
||||
assert not any("model_editable_context" in r.message for r in caplog.records)
|
||||
|
||||
def test_message_roles_are_valid(self):
|
||||
raw = json.loads((FIXTURES / "chatgpt_conversation.json").read_text())
|
||||
p = self._get_provider()
|
||||
|
||||
147
tests/test_utils.py
Normal file
147
tests/test_utils.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Tests for src/utils.py — filename generation, path building, redaction."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.utils import (
|
||||
build_export_path,
|
||||
format_token_status,
|
||||
generate_filename,
|
||||
redact_secrets,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateFilename:
|
||||
def test_basic_format(self):
|
||||
name = generate_filename("Hello World", "abc12345def", "2024-06-10T14:00:00Z")
|
||||
assert name == "2024-06-10_hello-world_abc12345.md"
|
||||
|
||||
def test_special_chars_slugified(self):
|
||||
# T-36: titles with punctuation must produce safe, OS-compatible filenames
|
||||
name = generate_filename("What's this?! A test.", "abc12345", "2024-06-01T00:00:00Z")
|
||||
assert "?" not in name
|
||||
assert "!" not in name
|
||||
assert "'" not in name
|
||||
assert " " not in name
|
||||
assert name.startswith("2024-06-01_")
|
||||
assert name.endswith("_abc12345.md")
|
||||
|
||||
def test_unicode_chars_handled(self):
|
||||
name = generate_filename("Héllo Wörld", "abc12345", "2024-06-01T00:00:00Z")
|
||||
assert " " not in name
|
||||
assert name.endswith("_abc12345.md")
|
||||
|
||||
def test_empty_title_becomes_untitled(self):
|
||||
name = generate_filename("", "abc12345", "2024-06-01T00:00:00Z")
|
||||
assert "untitled" in name
|
||||
|
||||
def test_id_truncated_to_8_chars(self):
|
||||
name = generate_filename("Test", "abcdefghijklmnop", "2024-06-01T00:00:00Z")
|
||||
assert name.endswith("_abcdefgh.md")
|
||||
|
||||
def test_long_title_truncated(self):
|
||||
long_title = "a" * 200
|
||||
name = generate_filename(long_title, "abc12345", "2024-06-01T00:00:00Z")
|
||||
# Slug is capped at 60 chars by max_length
|
||||
slug_part = name.split("_")[1]
|
||||
assert len(slug_part) <= 60
|
||||
|
||||
def test_date_comes_from_created_at(self):
|
||||
name = generate_filename("Test", "abc12345", "2023-11-25T00:00:00Z")
|
||||
assert name.startswith("2023-11-25_")
|
||||
|
||||
|
||||
class TestBuildExportPath:
|
||||
def test_default_structure_provider_project_year(self):
|
||||
path = build_export_path(
|
||||
Path("/exports"), "claude", "my-project", "2024-06-01T00:00:00Z", "file.md"
|
||||
)
|
||||
assert str(path) == "/exports/claude/my-project/2024/file.md"
|
||||
|
||||
def test_no_project_uses_no_project_slug(self):
|
||||
path = build_export_path(
|
||||
Path("/exports"), "chatgpt", None, "2024-06-01T00:00:00Z", "file.md"
|
||||
)
|
||||
assert "no-project" in str(path)
|
||||
|
||||
def test_provider_project_structure_omits_year(self):
|
||||
path = build_export_path(
|
||||
Path("/exports"), "claude", "proj", "2024-06-01T00:00:00Z", "file.md",
|
||||
structure="provider/project",
|
||||
)
|
||||
assert "2024" not in str(path)
|
||||
assert "proj" in str(path)
|
||||
|
||||
def test_provider_year_structure_omits_project(self):
|
||||
path = build_export_path(
|
||||
Path("/exports"), "claude", "proj", "2024-06-01T00:00:00Z", "file.md",
|
||||
structure="provider/year",
|
||||
)
|
||||
assert "proj" not in str(path)
|
||||
assert "2024" in str(path)
|
||||
|
||||
def test_project_name_with_spaces_is_slugified(self):
|
||||
path = build_export_path(
|
||||
Path("/exports"), "claude", "My Project Name!", "2024-06-01T00:00:00Z", "file.md"
|
||||
)
|
||||
assert " " not in str(path)
|
||||
assert "!" not in str(path)
|
||||
|
||||
|
||||
class TestRedactSecrets:
|
||||
def test_token_value_redacted(self):
|
||||
data = {"token": "supersecret"}
|
||||
result = redact_secrets(data)
|
||||
assert result["token"] == "[REDACTED]"
|
||||
|
||||
def test_session_key_redacted(self):
|
||||
result = redact_secrets({"sessionKey": "abc123"})
|
||||
assert result["sessionKey"] == "[REDACTED]"
|
||||
|
||||
def test_non_sensitive_key_unchanged(self):
|
||||
result = redact_secrets({"title": "My Chat", "id": "abc123"})
|
||||
assert result["title"] == "My Chat"
|
||||
assert result["id"] == "abc123"
|
||||
|
||||
def test_nested_dict_redacted(self):
|
||||
data = {"user": {"token": "secret", "name": "Alice"}}
|
||||
result = redact_secrets(data)
|
||||
assert result["user"]["token"] == "[REDACTED]"
|
||||
assert result["user"]["name"] == "Alice"
|
||||
|
||||
def test_list_of_dicts(self):
|
||||
data = [{"password": "p@ss"}, {"title": "chat"}]
|
||||
result = redact_secrets(data)
|
||||
assert result[0]["password"] == "[REDACTED]"
|
||||
assert result[1]["title"] == "chat"
|
||||
|
||||
|
||||
class TestFormatTokenStatus:
|
||||
def test_none_token_returns_not_set(self):
|
||||
assert format_token_status(None) == "[NOT SET]"
|
||||
|
||||
def test_empty_token_returns_not_set(self):
|
||||
assert format_token_status("") == "[NOT SET]"
|
||||
|
||||
def test_set_token_no_expiry(self):
|
||||
assert format_token_status("sometoken") == "[SET]"
|
||||
|
||||
def test_expired_token(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
expiry = datetime.now(tz=timezone.utc) - timedelta(days=1)
|
||||
result = format_token_status("tok", expiry)
|
||||
assert "EXPIRED" in result
|
||||
|
||||
def test_expiring_today_shows_hours(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
expiry = datetime.now(tz=timezone.utc) + timedelta(hours=3)
|
||||
result = format_token_status("tok", expiry)
|
||||
assert "expires in" in result
|
||||
assert "h" in result
|
||||
|
||||
def test_expiring_in_days(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
expiry = datetime.now(tz=timezone.utc) + timedelta(days=10, hours=12)
|
||||
result = format_token_status("tok", expiry)
|
||||
assert "10 days" in result
|
||||
Reference in New Issue
Block a user