7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
602 lines
22 KiB
Python
602 lines
22 KiB
Python
"""Tests for file storage implementation."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.storage.file import FileReportStorage
|
|
|
|
|
|
class TestFileReportStorageInit:
|
|
"""Tests for FileReportStorage initialization."""
|
|
|
|
def test_uses_default_directory_when_none(self, tmp_path):
|
|
"""Should use default research outputs directory when none provided."""
|
|
with patch(
|
|
"local_deep_research.storage.file.get_research_outputs_directory",
|
|
return_value=tmp_path,
|
|
):
|
|
storage = FileReportStorage()
|
|
assert storage.base_dir == tmp_path
|
|
|
|
def test_uses_provided_directory(self, tmp_path):
|
|
"""Should use provided directory."""
|
|
custom_dir = tmp_path / "custom"
|
|
storage = FileReportStorage(base_dir=custom_dir)
|
|
assert storage.base_dir == custom_dir
|
|
|
|
def test_creates_directory_if_not_exists(self, tmp_path):
|
|
"""Should create directory if it doesn't exist."""
|
|
new_dir = tmp_path / "new_reports"
|
|
storage = FileReportStorage(base_dir=new_dir)
|
|
assert storage.base_dir.exists()
|
|
|
|
|
|
class TestGetReportPath:
|
|
"""Tests for _get_report_path method."""
|
|
|
|
def test_returns_md_file_path(self, tmp_path):
|
|
"""Should return path with .md extension."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
path = storage._get_report_path("test-uuid")
|
|
assert path == tmp_path / "test-uuid.md"
|
|
|
|
def test_returns_path_object(self, tmp_path):
|
|
"""Should return Path object."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
path = storage._get_report_path("test-uuid")
|
|
assert isinstance(path, Path)
|
|
|
|
|
|
class TestGetMetadataPath:
|
|
"""Tests for _get_metadata_path method."""
|
|
|
|
def test_returns_metadata_json_path(self, tmp_path):
|
|
"""Should return path with _metadata.json suffix."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
path = storage._get_metadata_path("test-uuid")
|
|
assert path == tmp_path / "test-uuid_metadata.json"
|
|
|
|
|
|
class TestPathTraversalContainment:
|
|
"""Regression tests for the #3090 traversal guard: a research_id that
|
|
escapes base_dir must be rejected, not resolved to an arbitrary on-disk
|
|
path. (research_ids are normally server-generated UUIDs; this is
|
|
defense-in-depth.)"""
|
|
|
|
def test_report_path_rejects_parent_traversal(self, tmp_path):
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
with pytest.raises(ValueError, match="Path traversal attempt"):
|
|
storage._get_report_path("../../etc/passwd")
|
|
|
|
def test_metadata_path_rejects_parent_traversal(self, tmp_path):
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
with pytest.raises(ValueError, match="Path traversal attempt"):
|
|
storage._get_metadata_path("../../etc/passwd")
|
|
|
|
def test_report_path_rejects_single_level_escape(self, tmp_path):
|
|
storage = FileReportStorage(base_dir=tmp_path / "reports")
|
|
with pytest.raises(ValueError, match="Path traversal attempt"):
|
|
storage._get_report_path("../escapee")
|
|
|
|
def test_normal_id_stays_within_base_dir(self, tmp_path):
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
path = storage._get_report_path("9f8e-uuid-1234")
|
|
assert path.is_relative_to(tmp_path.resolve())
|
|
assert path.name == "9f8e-uuid-1234.md"
|
|
|
|
|
|
class TestSaveReport:
|
|
"""Tests for save_report method."""
|
|
|
|
def test_saves_report_content(self, tmp_path, sample_report_content):
|
|
"""Should save report content to file."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
# Patch at source location since it's imported inside the method
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_file_verified"
|
|
) as mock_write:
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_json_verified"
|
|
):
|
|
result = storage.save_report("test-uuid", sample_report_content)
|
|
|
|
assert result is True
|
|
mock_write.assert_called_once()
|
|
|
|
def test_saves_metadata_when_provided(
|
|
self, tmp_path, sample_report_content, sample_metadata
|
|
):
|
|
"""Should save metadata when provided."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_file_verified"
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_json_verified"
|
|
) as mock_json:
|
|
storage.save_report(
|
|
"test-uuid", sample_report_content, metadata=sample_metadata
|
|
)
|
|
|
|
mock_json.assert_called_once()
|
|
|
|
def test_skips_metadata_when_not_provided(
|
|
self, tmp_path, sample_report_content
|
|
):
|
|
"""Should skip metadata save when not provided."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_file_verified"
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_json_verified"
|
|
) as mock_json:
|
|
storage.save_report("test-uuid", sample_report_content)
|
|
|
|
mock_json.assert_not_called()
|
|
|
|
def test_returns_false_on_error(self, tmp_path, sample_report_content):
|
|
"""Should return False on error."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_file_verified",
|
|
side_effect=Exception("write error"),
|
|
):
|
|
result = storage.save_report("test-uuid", sample_report_content)
|
|
|
|
assert result is False
|
|
|
|
def test_save_report_gates_on_report_enable_file_backup(
|
|
self, tmp_path, sample_report_content
|
|
):
|
|
"""Regression: the verifier gate must consult the same setting key
|
|
that the UI toggle writes (``report.enable_file_backup``). The
|
|
previous literal ``storage.allow_file_backup`` was never registered
|
|
in ``default_settings.json``, so every save raised
|
|
``FileWriteSecurityError`` regardless of the user's toggle.
|
|
"""
|
|
from local_deep_research.security.file_write_verifier import (
|
|
FileWriteSecurityError,
|
|
)
|
|
|
|
captured = {}
|
|
|
|
def fake_write_file_verified(path, content, setting_name, **_kwargs):
|
|
captured["setting_name"] = setting_name
|
|
raise FileWriteSecurityError("ignored")
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
with patch(
|
|
"local_deep_research.security.file_write_verifier.write_file_verified",
|
|
fake_write_file_verified,
|
|
):
|
|
result = storage.save_report("abc", sample_report_content)
|
|
|
|
# The exception is swallowed by FileReportStorage.save_report, so the
|
|
# outer result is False -- the bug we care about is the *key* passed
|
|
# to the verifier, not the return value.
|
|
assert result is False
|
|
assert captured["setting_name"] == "report.enable_file_backup"
|
|
|
|
def test_save_metadata_gates_on_report_enable_file_backup(
|
|
self, tmp_path, sample_report_content, sample_metadata
|
|
):
|
|
"""The metadata write must consult the same UI-visible key as the
|
|
body write. A divergence here would silently drop the
|
|
``_metadata.json`` sidecar even when the body lands on disk.
|
|
"""
|
|
from local_deep_research.security.file_write_verifier import (
|
|
FileWriteSecurityError,
|
|
)
|
|
|
|
captured = {}
|
|
|
|
def fake_write_file_verified(path, content, setting_name, **_kwargs):
|
|
# Allow the body write through so the metadata branch runs.
|
|
captured.setdefault("body_setting_name", setting_name)
|
|
|
|
def fake_write_json_verified(path, payload, setting_name, **_kwargs):
|
|
captured["metadata_setting_name"] = setting_name
|
|
raise FileWriteSecurityError("ignored")
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.file_write_verifier.write_file_verified",
|
|
fake_write_file_verified,
|
|
),
|
|
patch(
|
|
"local_deep_research.security.file_write_verifier.write_json_verified",
|
|
fake_write_json_verified,
|
|
),
|
|
):
|
|
storage.save_report(
|
|
"abc", sample_report_content, metadata=sample_metadata
|
|
)
|
|
|
|
assert captured["body_setting_name"] == "report.enable_file_backup"
|
|
assert captured["metadata_setting_name"] == "report.enable_file_backup"
|
|
|
|
def test_save_report_and_factory_agree_on_setting_key(
|
|
self, tmp_path, sample_report_content, mock_session
|
|
):
|
|
"""End-to-end key-alignment check: the key consulted by
|
|
``FileReportStorage.save_report`` must match the key consulted by
|
|
``get_report_storage``. If either side drifts, a user who flipped
|
|
the UI toggle would still see no files on disk.
|
|
"""
|
|
from local_deep_research.storage.factory import get_report_storage
|
|
|
|
factory_calls = []
|
|
|
|
def fake_get_setting(name, settings_snapshot=None):
|
|
factory_calls.append(name)
|
|
return True
|
|
|
|
file_calls = []
|
|
|
|
from local_deep_research.security.file_write_verifier import (
|
|
FileWriteSecurityError,
|
|
)
|
|
|
|
def fake_write_file_verified(path, content, setting_name, **_kwargs):
|
|
file_calls.append(setting_name)
|
|
raise FileWriteSecurityError("ignored")
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.storage.factory.get_setting_from_snapshot",
|
|
fake_get_setting,
|
|
),
|
|
patch(
|
|
"local_deep_research.security.file_write_verifier.write_file_verified",
|
|
fake_write_file_verified,
|
|
),
|
|
):
|
|
# Factory side: which key unlocks file backup?
|
|
factory = get_report_storage(session=mock_session)
|
|
assert factory.enable_file_storage is True
|
|
|
|
# Storage side: which key gates the disk write?
|
|
fs = FileReportStorage(base_dir=tmp_path)
|
|
fs.save_report("abc", sample_report_content)
|
|
|
|
assert factory_calls == ["report.enable_file_backup"]
|
|
assert file_calls == ["report.enable_file_backup"]
|
|
assert factory_calls == file_calls
|
|
|
|
|
|
class TestGetReport:
|
|
"""Tests for get_report method."""
|
|
|
|
def test_returns_content_when_file_exists(
|
|
self, tmp_path, sample_report_content
|
|
):
|
|
"""Should return content when file exists."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text(sample_report_content, encoding="utf-8")
|
|
|
|
result = storage.get_report("test-uuid")
|
|
|
|
assert result == sample_report_content
|
|
|
|
def test_returns_none_when_file_not_exists(self, tmp_path):
|
|
"""Should return None when file doesn't exist."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
result = storage.get_report("nonexistent-uuid")
|
|
|
|
assert result is None
|
|
|
|
def test_returns_none_on_error(self, tmp_path):
|
|
"""Should return None on read error."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text("content", encoding="utf-8")
|
|
|
|
with patch("builtins.open", side_effect=Exception("read error")):
|
|
result = storage.get_report("test-uuid")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestGetReportWithMetadata:
|
|
"""Tests for get_report_with_metadata method."""
|
|
|
|
def test_returns_content_and_metadata(
|
|
self, tmp_path, sample_report_content, sample_metadata
|
|
):
|
|
"""Should return both content and metadata."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
# Create files
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text(sample_report_content, encoding="utf-8")
|
|
|
|
metadata_path = tmp_path / "test-uuid_metadata.json"
|
|
metadata_path.write_text(json.dumps(sample_metadata), encoding="utf-8")
|
|
|
|
result = storage.get_report_with_metadata("test-uuid")
|
|
|
|
assert result["content"] == sample_report_content
|
|
assert result["metadata"] == sample_metadata
|
|
|
|
def test_returns_empty_metadata_when_missing(
|
|
self, tmp_path, sample_report_content
|
|
):
|
|
"""Should return empty metadata when file missing."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text(sample_report_content, encoding="utf-8")
|
|
|
|
result = storage.get_report_with_metadata("test-uuid")
|
|
|
|
assert result["content"] == sample_report_content
|
|
assert result["metadata"] == {}
|
|
|
|
def test_returns_none_when_report_missing(self, tmp_path):
|
|
"""Should return None when report doesn't exist."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
result = storage.get_report_with_metadata("nonexistent")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestDeleteReport:
|
|
"""Tests for delete_report method."""
|
|
|
|
def test_deletes_report_file(self, tmp_path, sample_report_content):
|
|
"""Should delete report file."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text(sample_report_content, encoding="utf-8")
|
|
|
|
result = storage.delete_report("test-uuid")
|
|
|
|
assert result is True
|
|
assert not report_path.exists()
|
|
|
|
def test_deletes_metadata_file(
|
|
self, tmp_path, sample_report_content, sample_metadata
|
|
):
|
|
"""Should delete metadata file."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text(sample_report_content, encoding="utf-8")
|
|
|
|
metadata_path = tmp_path / "test-uuid_metadata.json"
|
|
metadata_path.write_text(json.dumps(sample_metadata), encoding="utf-8")
|
|
|
|
storage.delete_report("test-uuid")
|
|
|
|
assert not metadata_path.exists()
|
|
|
|
def test_returns_false_when_files_not_exist(self, tmp_path):
|
|
"""Should return False when no files to delete."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
result = storage.delete_report("nonexistent")
|
|
|
|
assert result is False
|
|
|
|
def test_returns_false_on_error(self, tmp_path, sample_report_content):
|
|
"""Should return False on delete error."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text(sample_report_content, encoding="utf-8")
|
|
|
|
with patch.object(
|
|
Path, "unlink", side_effect=Exception("delete error")
|
|
):
|
|
result = storage.delete_report("test-uuid")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestReportExists:
|
|
"""Tests for report_exists method."""
|
|
|
|
def test_returns_true_when_file_exists(
|
|
self, tmp_path, sample_report_content
|
|
):
|
|
"""Should return True when file exists."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
report_path = tmp_path / "test-uuid.md"
|
|
report_path.write_text(sample_report_content, encoding="utf-8")
|
|
|
|
result = storage.report_exists("test-uuid")
|
|
|
|
assert result is True
|
|
|
|
def test_returns_false_when_file_not_exists(self, tmp_path):
|
|
"""Should return False when file doesn't exist."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
result = storage.report_exists("nonexistent")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestListReports:
|
|
"""Tests for list_reports method."""
|
|
|
|
def test_returns_list_of_report_dicts(self, tmp_path):
|
|
"""Should return list of dicts for each .md file."""
|
|
(tmp_path / "report-1.md").write_text("# Report 1", encoding="utf-8")
|
|
(tmp_path / "report-2.md").write_text("# Report 2", encoding="utf-8")
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
assert len(result) == 2
|
|
ids = {r["id"] for r in result}
|
|
assert ids == {"report-1", "report-2"}
|
|
for r in result:
|
|
assert "id" in r
|
|
assert "query" in r
|
|
assert "mode" in r
|
|
assert "created_at" in r
|
|
assert "completed_at" in r
|
|
|
|
def test_returns_empty_list_when_no_reports(self, tmp_path):
|
|
"""Should return empty list when directory has no .md files."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
assert result == []
|
|
|
|
def test_loads_metadata_from_json(self, tmp_path):
|
|
"""Should populate query and mode from metadata JSON when available."""
|
|
(tmp_path / "report-1.md").write_text("# Report", encoding="utf-8")
|
|
metadata = {"query": "test query", "mode": "detailed"}
|
|
(tmp_path / "report-1_metadata.json").write_text(
|
|
json.dumps(metadata), encoding="utf-8"
|
|
)
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["query"] == "test query"
|
|
assert result[0]["mode"] == "detailed"
|
|
|
|
def test_handles_missing_metadata(self, tmp_path):
|
|
"""Should return None query and 'unknown' mode when no metadata file."""
|
|
(tmp_path / "report-1.md").write_text("# Report", encoding="utf-8")
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["query"] is None
|
|
assert result[0]["mode"] == "unknown"
|
|
|
|
def test_returns_empty_list_on_error(self, tmp_path):
|
|
"""Should return empty list on error."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
|
|
with patch.object(Path, "glob", side_effect=Exception("glob error")):
|
|
result = storage.list_reports()
|
|
|
|
assert result == []
|
|
|
|
def test_accepts_username_parameter(self, tmp_path):
|
|
"""Should accept username parameter without error."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports(username="testuser")
|
|
|
|
assert result == []
|
|
|
|
def test_ignores_non_md_files(self, tmp_path):
|
|
"""Should only list .md files, ignoring other file types."""
|
|
(tmp_path / "report-1.md").write_text("# Report", encoding="utf-8")
|
|
(tmp_path / "report-1_metadata.json").write_text("{}", encoding="utf-8")
|
|
(tmp_path / "notes.txt").write_text("notes", encoding="utf-8")
|
|
(tmp_path / "data.csv").write_text("a,b", encoding="utf-8")
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["id"] == "report-1"
|
|
|
|
def test_created_at_is_iso_timestamp(self, tmp_path):
|
|
"""Should return created_at as ISO 8601 string, not raw float."""
|
|
(tmp_path / "report-1.md").write_text("# Report", encoding="utf-8")
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
created_at = result[0]["created_at"]
|
|
assert isinstance(created_at, str)
|
|
# ISO format should contain 'T' separator and timezone info
|
|
assert "T" in created_at
|
|
|
|
def test_handles_corrupt_metadata_json(self, tmp_path):
|
|
"""Should gracefully handle corrupt metadata JSON."""
|
|
(tmp_path / "report-1.md").write_text("# Report", encoding="utf-8")
|
|
(tmp_path / "report-1_metadata.json").write_text(
|
|
"not valid json{{{", encoding="utf-8"
|
|
)
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["query"] is None
|
|
assert result[0]["mode"] == "unknown"
|
|
|
|
def test_metadata_with_extra_fields_only_uses_query_and_mode(
|
|
self, tmp_path
|
|
):
|
|
"""Should extract only query and mode from metadata."""
|
|
(tmp_path / "report-1.md").write_text("# Report", encoding="utf-8")
|
|
metadata = {
|
|
"query": "my query",
|
|
"mode": "detailed",
|
|
"extra_field": "ignored",
|
|
"sources_count": 42,
|
|
}
|
|
(tmp_path / "report-1_metadata.json").write_text(
|
|
json.dumps(metadata), encoding="utf-8"
|
|
)
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage.list_reports()
|
|
|
|
assert set(result[0].keys()) == {
|
|
"id",
|
|
"query",
|
|
"mode",
|
|
"created_at",
|
|
"completed_at",
|
|
}
|
|
|
|
|
|
class TestLoadMetadata:
|
|
"""Tests for _load_metadata helper method."""
|
|
|
|
def test_returns_dict_when_metadata_exists(self, tmp_path):
|
|
"""Should return parsed dict from metadata JSON."""
|
|
metadata = {"query": "test", "mode": "quick"}
|
|
(tmp_path / "r1_metadata.json").write_text(
|
|
json.dumps(metadata), encoding="utf-8"
|
|
)
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage._load_metadata("r1")
|
|
|
|
assert result == metadata
|
|
|
|
def test_returns_none_when_no_metadata_file(self, tmp_path):
|
|
"""Should return None when metadata file doesn't exist."""
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage._load_metadata("nonexistent")
|
|
|
|
assert result is None
|
|
|
|
def test_returns_none_on_invalid_json(self, tmp_path):
|
|
"""Should return None when metadata file has invalid JSON."""
|
|
(tmp_path / "r1_metadata.json").write_text("{{bad", encoding="utf-8")
|
|
|
|
storage = FileReportStorage(base_dir=tmp_path)
|
|
result = storage._load_metadata("r1")
|
|
|
|
assert result is None
|