11import json
22from pathlib import Path
33
4+ import jsonschema
5+
46FIXTURES = Path (__file__ ).parent / "fixtures"
57
68
@@ -21,7 +23,7 @@ def load_signals_wire_fixture() -> dict:
2123
2224
2325def load_items_wire_fixture () -> dict :
24- """The `GET /api/news/items/` response shape (news-story v1 ), the single
26+ """The `GET /api/news/items/` response shape (news-story v2 ), the single
2527 recorded record of what the adapter's items path parses — see
2628 docs/integrations/finsearch-news-items.md.
2729
@@ -40,19 +42,19 @@ def load_items_wire_fixture() -> dict:
4042 return json .loads ((FIXTURES / "items-wire-fixture.json" ).read_text ())
4143
4244
43- # Every key `GET /api/news/items/` puts on a story, per the news-story v1 table
45+ # Every key `GET /api/news/items/` puts on a story, per the news-story v2 table
4446# in docs/integrations/finsearch-news-items.md. The first five are the shared
45- # vocabulary; guid/description/score are the items-only extras.
47+ # vocabulary; guid/description/editorial_score are the items-only extras.
4648ITEMS_STORY_KEYS = {"headline" , "url" , "source" , "published" , "tickers" ,
47- "guid" , "description" , "score " }
49+ "guid" , "description" , "editorial_score " }
4850
4951
5052def test_items_wire_fixture_matches_contract_essentials ():
5153 """Pins the items fixture to the documented contract, so a future producer
5254 rename has to change this file — and be seen in review — rather than being
5355 absorbed silently into whichever test dict happened to mention the field."""
5456 body = load_items_wire_fixture ()
55- assert body ["schema_version" ] == 1
57+ assert body ["schema_version" ] == 2
5658 assert set (body ) == {"schema_version" , "items" , "count" , "batch" }
5759 items = body ["items" ]
5860 assert isinstance (items , list ) and items
@@ -72,10 +74,15 @@ def test_items_wire_fixture_does_not_speak_the_retired_vocabulary():
7274 """Regression guard on the 2026-07-14 incident: `title`/`link` are the
7375 on-disk RSS-native names and must never reappear on the wire fixture — the
7476 rename happens at AF's boundary, so a consumer that sees them is looking at
75- a pre-v1 shape."""
77+ a pre-v1 shape. Bare `score` is retired the same way (news-story v2 renamed
78+ it `editorial_score`), but note it is retired for a stricter reason: the
79+ items endpoint has no boundary normalizer, so `editorial_score` sits in the
80+ producer's REQUIRED_FIELDS and a pre-rename batch trips the batch-level
81+ poison pill and 404s rather than being served as v1."""
7682 for item in load_items_wire_fixture ()["items" ]:
7783 assert "title" not in item
7884 assert "link" not in item
85+ assert "score" not in item
7986
8087
8188def test_items_fixture_is_the_batch_the_signals_fixture_came_from ():
@@ -102,14 +109,54 @@ def test_items_fixture_is_the_batch_the_signals_fixture_came_from():
102109
103110def test_fixture_matches_contract_essentials ():
104111 body = load_signals_fixture ()
105- assert body ["schema_version" ] in ( 1 , 2 ) # transitional; PR-2 pins == 2
112+ assert body ["schema_version" ] == 2
106113 assert isinstance (body ["signals" ], dict ) and body ["signals" ]
107114 sample = next (iter (body ["signals" ].values ()))
108- for field in ("sentiment" , "score " , "rationale" , "headline" , "source " ,
109- "url" , "published" , "guid" , "n_articles" ):
115+ for field in ("sentiment" , "sentiment_score " , "rationale" , "headline" ,
116+ "source" , " url" , "published" , "guid" , "n_articles" ):
110117 assert field in sample
111118
112119
120+ def test_signals_fixture_validates_against_the_vendored_producer_schema ():
121+ """Both files are copied verbatim from FinSearch (`Heartbeat/schemas/` and
122+ `Heartbeat/tests/fixtures/`), so checking one against the other is what
123+ makes the vendored pair self-policing.
124+
125+ Until now nothing in the suite loaded the schema at all — it was inert
126+ documentation, which is how it sat pinned at v1 while the producer moved to
127+ v2 and no test noticed. v2 sets additionalProperties:false and requires
128+ `sentiment_score`, so this is also the assertion that turns a re-vendored
129+ fixture carrying a stray legacy `score` into a CI failure.
130+
131+ Only the on-disk fixture is validated, never the wire one: the wire shape
132+ deliberately violates this schema (it drops the three _PUBLIC_STRIP
133+ required fields and appends `staleness_hours`), which is precisely the
134+ distinction test_wire_fixture_reflects_public_projection guards."""
135+ schema = json .loads ((FIXTURES / "signals-v2.schema.json" ).read_text ())
136+ jsonschema .validate (instance = load_signals_fixture (), schema = schema )
137+
138+
139+ def test_signals_fixtures_do_not_speak_the_retired_score_vocabulary ():
140+ """The `score` -> `sentiment_score` rename is hard, not a dual-write:
141+ signals-v2.schema.json sets additionalProperties:false and requires
142+ sentiment_score, and FinSearch normalizes at its API boundary so `score`
143+ never reaches the wire (whether the artifact is read as latest or via
144+ ?as_of). A fixture still carrying `score` would describe a shape the
145+ producer cannot emit — which is precisely the failure this suite had:
146+ v1-pinned fixtures stay green while prod serves v2, so they fail when you
147+ fix them and pass when you are wrong."""
148+ for body in (load_signals_fixture (), load_signals_wire_fixture ()):
149+ for sig in body ["signals" ].values ():
150+ assert "score" not in sig
151+ # Deliberately stricter than the vendored schema, whose `number`
152+ # admits ints: news_signals.py builds this via float() + round(),
153+ # so float — not "any number" — is the real producer invariant.
154+ # Don't relax it to (int, float): the schema check above already
155+ # covers "is a number", and only for the on-disk fixture, so this
156+ # is also the wire fixture's only type guard.
157+ assert isinstance (sig ["sentiment_score" ], float )
158+
159+
113160def test_wire_fixture_reflects_public_projection ():
114161 """Guards the wire/on-disk distinction the adapter depends on: the wire
115162 shape carries `staleness_hours` and omits the three fields `_PUBLIC_STRIP`
0 commit comments