fix(api): publish a validated dataset, and stop truncating search
Reload cleared the caches first and rebuilt afterwards, so any failure left the API serving nothing, and requests arriving mid-reload saw a half-swapped state. It now builds and validates the replacement frames, place registry, reverse index and sitemaps off the request loop, then publishes them in one synchronous step under a lock. A failed reload returns 503 and keeps the previous data. Sitemap regeneration takes the same path rather than clearing the live registry up front. Typesense search returned at most one page of hits and used an empty list for both "no matches" and "search is down", so a genuine empty result silently fell back to substring matching. It now pages through every candidate and returns None only on failure; the fallback matches literally, since a query containing regex metacharacters used to throw. Empty datasets answer 503 rather than 200-with-nothing or a misleading 404, so callers can tell an outage from an absent school. Adds /api/release, which reports the build identity baked into the image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
38bc17cab3
commit
9b75f54206
4 files changed
+253
-47
No files matched your search
@@ -0,0 +1,68 @@
|
||||
"""Publication must preserve the current dataset until every replacement is ready."""
|
||||
import asyncio
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as api, data_loader
|
||||
from backend.tests.test_sixth_form_flag import _schools_df
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
old = _schools_df()
|
||||
monkeypatch.setattr(data_loader, '_df_cache', old)
|
||||
monkeypatch.setattr(data_loader, '_df_latest_cache', old)
|
||||
monkeypatch.setattr(api, '_place_registry', {'old': 'registry'})
|
||||
monkeypatch.setattr(api, '_place_index', {'old': 'index'})
|
||||
monkeypatch.setattr(api, '_place_index_source', api._place_registry)
|
||||
monkeypatch.setattr(api, '_sitemaps', {'old.xml': 'old sitemap'})
|
||||
monkeypatch.setattr(api, '_publication_lock', asyncio.Lock())
|
||||
monkeypatch.setattr(api.limiter, 'enabled', False)
|
||||
api.app.dependency_overrides[api.verify_admin_api_key] = lambda: True
|
||||
yield TestClient(api.app, raise_server_exceptions=False)
|
||||
api.app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def state():
|
||||
return (data_loader._df_cache, data_loader._df_latest_cache, api._place_registry,
|
||||
api._place_index, api._place_index_source, api._sitemaps)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', ['empty', 'database', 'sitemap', 'duplicate'])
|
||||
def test_failed_reload_preserves_every_published_object(client, monkeypatch, failure):
|
||||
before = state()
|
||||
df = _schools_df()
|
||||
if failure == 'empty':
|
||||
df = pd.DataFrame()
|
||||
if failure == 'duplicate':
|
||||
df = pd.concat([df, df.iloc[:1]], ignore_index=True)
|
||||
def load():
|
||||
if failure == 'database':
|
||||
raise RuntimeError('database unavailable')
|
||||
return df
|
||||
monkeypatch.setattr(api, 'load_school_data_as_dataframe', load)
|
||||
if failure == 'sitemap':
|
||||
monkeypatch.setattr(api, 'build_sitemaps', lambda *args: (_ for _ in ()).throw(RuntimeError('bad XML')))
|
||||
response = client.post('/api/admin/reload')
|
||||
assert response.status_code == 503
|
||||
assert all(a is b for a, b in zip(before, state()))
|
||||
|
||||
|
||||
def test_success_publishes_school_data_places_and_sitemaps(client, monkeypatch):
|
||||
df = _schools_df()
|
||||
df.loc[0, 'school_name'] = 'Replacement School'
|
||||
monkeypatch.setattr(api, 'load_school_data_as_dataframe', lambda: df)
|
||||
response = client.post('/api/admin/reload')
|
||||
assert response.status_code == 200
|
||||
assert data_loader.load_school_data() is df
|
||||
assert data_loader.load_latest_school_data().iloc[0].school_name == 'Replacement School'
|
||||
assert api._place_index_source is api._place_registry
|
||||
assert 'old.xml' not in api._sitemaps
|
||||
assert 'replacement-school' in api._sitemaps['schools-1.xml']
|
||||
|
||||
|
||||
def test_failed_sitemap_regeneration_keeps_existing_publication(client, monkeypatch):
|
||||
before = state()
|
||||
monkeypatch.setattr(api, 'build_sitemaps', lambda *args: (_ for _ in ()).throw(RuntimeError('bad XML')))
|
||||
assert client.post('/api/admin/regenerate-sitemap').status_code == 503
|
||||
assert all(a is b for a, b in zip(before, state()))
|
||||
Reference in new issue
Block a user