Skip to content

Commit 7ef46dd

Browse files
committed
Harden briefing fixture validation
1 parent aaf8ca3 commit 7ef46dd

4 files changed

Lines changed: 102 additions & 3 deletions

File tree

examples/source_linked_briefing/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ python examples/source_linked_briefing/briefing.py \
2525
--output-dir briefing-output
2626
```
2727

28-
The fixture contains fictional `example.com` articles. It contains no customer data, publisher article bodies, or credentials.
28+
The fixture contains fictional `example.com` articles. It contains no customer data, publisher article bodies, or credentials. Its `_fixture_generated_at` value keeps both output files identical across runs.
29+
30+
For another saved Search API response, add `_fixture_generated_at` at the top level or pass `--generated-at` explicitly.
2931

3032
## Run a live search
3133

examples/source_linked_briefing/briefing.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ def parse_args():
1414
parser.add_argument("--keywords", default="artificial intelligence")
1515
parser.add_argument("--language", default="en")
1616
parser.add_argument("--output-dir", type=Path, default=Path("briefing-output"))
17-
parser.add_argument("--generated-at", help=argparse.SUPPRESS)
17+
parser.add_argument(
18+
"--generated-at",
19+
help="Set the output timestamp for a custom fixture.",
20+
)
1821
return parser.parse_args()
1922

2023

@@ -55,6 +58,30 @@ def validate_response(response):
5558
raise ValueError("Search API response news must be a list")
5659
if any(not isinstance(article, dict) for article in response["news"]):
5760
raise ValueError("Every news item must be an object")
61+
for article in response["news"]:
62+
for field in ("title", "description", "url", "published", "language"):
63+
value = article.get(field)
64+
if value is not None and not isinstance(value, str):
65+
raise ValueError("news item {} must be a string".format(field))
66+
category = article.get("category")
67+
if category is not None and (
68+
not isinstance(category, list)
69+
or any(not isinstance(item, str) for item in category)
70+
):
71+
raise ValueError("news item category must be a list of strings")
72+
73+
74+
def resolve_generated_at(args, response):
75+
if args.generated_at:
76+
return args.generated_at
77+
if args.fixture:
78+
fixture_time = response.get("_fixture_generated_at")
79+
if not isinstance(fixture_time, str) or not fixture_time:
80+
raise ValueError(
81+
"Fixture must include _fixture_generated_at or use --generated-at"
82+
)
83+
return fixture_time
84+
return datetime.now(timezone.utc).isoformat()
5885

5986

6087
def build_output(response, generated_at):
@@ -86,7 +113,8 @@ def main():
86113
if args.fixture
87114
else load_live_response(args.keywords, args.language)
88115
)
89-
generated_at = args.generated_at or datetime.now(timezone.utc).isoformat()
116+
validate_response(response)
117+
generated_at = resolve_generated_at(args, response)
90118
markdown, structured = build_output(response, generated_at)
91119
except (OSError, ValueError, json.JSONDecodeError) as exc:
92120
raise SystemExit("error: {}".format(exc))

examples/source_linked_briefing/fixtures/search_response.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"_fixture_generated_at": "2026-07-25T00:00:00Z",
23
"status": "ok",
34
"news": [
45
{

tests/test_source_linked_briefing_example.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,46 @@ def test_fixture_mode_rejects_malformed_search_response(tmp_path):
133133
assert not output_dir.exists()
134134

135135

136+
def test_fixture_mode_rejects_malformed_article_fields(tmp_path):
137+
fixture = tmp_path / "search-response.json"
138+
fixture.write_text(
139+
json.dumps(
140+
{
141+
"status": "ok",
142+
"news": [
143+
{
144+
"title": ["not", "a", "string"],
145+
"url": "https://news.example/invalid",
146+
"published": "2026-07-25T12:00:00Z",
147+
}
148+
],
149+
}
150+
),
151+
encoding="utf-8",
152+
)
153+
output_dir = tmp_path / "output"
154+
155+
result = subprocess.run(
156+
[
157+
sys.executable,
158+
str(EXAMPLE),
159+
"--fixture",
160+
str(fixture),
161+
"--output-dir",
162+
str(output_dir),
163+
"--generated-at",
164+
"2026-07-25T13:00:00Z",
165+
],
166+
capture_output=True,
167+
check=False,
168+
text=True,
169+
)
170+
171+
assert result.returncode != 0
172+
assert "news item title must be a string" in result.stderr
173+
assert not output_dir.exists()
174+
175+
136176
def test_live_mode_requires_api_key(tmp_path):
137177
env = os.environ.copy()
138178
env.pop("CURRENTS_API_KEY", None)
@@ -181,6 +221,34 @@ def test_checked_in_fixture_runs_without_network_or_credentials(tmp_path):
181221
assert "https://example.com/energy/storage-policy" in markdown
182222

183223

224+
def test_checked_in_fixture_produces_identical_output_on_every_run(tmp_path):
225+
first_output = tmp_path / "first"
226+
second_output = tmp_path / "second"
227+
228+
for output_dir in (first_output, second_output):
229+
result = subprocess.run(
230+
[
231+
sys.executable,
232+
str(EXAMPLE),
233+
"--fixture",
234+
str(CHECKED_IN_FIXTURE),
235+
"--output-dir",
236+
str(output_dir),
237+
],
238+
capture_output=True,
239+
check=False,
240+
text=True,
241+
)
242+
assert result.returncode == 0, result.stderr
243+
244+
assert (first_output / "briefing.md").read_bytes() == (
245+
second_output / "briefing.md"
246+
).read_bytes()
247+
assert (first_output / "briefing.json").read_bytes() == (
248+
second_output / "briefing.json"
249+
).read_bytes()
250+
251+
184252
def test_live_mode_uses_the_sdk_search_result(tmp_path):
185253
fake_package = tmp_path / "fake-sdk" / "currentsapi"
186254
fake_package.mkdir(parents=True)

0 commit comments

Comments
 (0)