"""Tests for the EIA data-browser widgets and reverse proxy.""" import json import shutil import subprocess import tempfile from pathlib import Path import pytest from fastapi import Request from openbb_us_eia import browsers MAPS_HTML = """

Lower 48

Overview & boundaries

PDF JPG PDF

Historical basins

JPG ZIP BOE
""" PROXY_PREFIX = "/api/v1/eia_proxy" INTERNATIONAL = browsers.EIA_DATA_BROWSERS["international"]["path"] IMPORTS = browsers.EIA_DATA_BROWSERS["petroleum_imports"]["path"] ELECTRICITY = browsers.EIA_DATA_BROWSERS["electricity"]["path"] def make_info(path, query="", method="GET", body=b"", referer="", user=""): """Build the request_info mapping the proxy endpoints consume.""" return { "url": f"http://test{path}", "query": query, "path": path, "method": method, "body": body, "content_type": "application/x-www-form-urlencoded" if body else "", "referer": referer, "user": user, } @pytest.fixture(autouse=True) def isolate_state(monkeypatch, tmp_path): """Keep every test off the shared caches, disk, and view stores.""" monkeypatch.setattr( "openbb_core.app.utils.get_user_cache_directory", lambda: str(tmp_path / "cache"), ) monkeypatch.setattr(browsers, "_MAPS_CATALOG", None) stores = ( browsers._PROXY_CACHE, browsers._REDIRECTS, browsers._REWRITE_CACHE, browsers._TABLE_ROWS, browsers._LAST_DATA, browsers._VIEW_DATA, browsers._CURRENT_VIEW, browsers._WIDGET_STATE, browsers._INTL_LABELS, browsers._IMPORTS_LABELS, ) for store in stores: store.clear() yield for store in stores: store.clear() class FakeUpstream: """Stand in for eia.gov at the one place the code touches the network.""" def __init__(self, responses): self.responses = responses self.calls = [] def __call__(self, target): self.calls.append(target) result = self.responses[target] if isinstance(result, BaseException): raise result body, content_type = result[0], result[1] status = result[2] if len(result) > 2 else 200 landed = result[3] if len(result) > 3 else target return body, content_type, status, landed def install(self, monkeypatch): monkeypatch.setattr(browsers, "_fetch_sync", self) return self class TestRequestInfo: """The proxy reads deepcopy-safe primitives off the request.""" @pytest.mark.asyncio async def test_extracts_url_query_path_and_method(self): request = Request( scope={ "type": "http", "method": "GET", "scheme": "http", "server": ("test", 80), "path": "/api/v1/eia_proxy/coal/", "query_string": b"a=1", "headers": [(b"x-openbb-user", b"analyst@example.com")], } ) info = await browsers.request_info(request) assert info["url"] == "http://test/api/v1/eia_proxy/coal/" assert info["query"] == "a=1" assert info["path"] == "/api/v1/eia_proxy/coal/" assert info["method"] == "GET" assert "analyst@example.com" not in json.dumps(info, default=str) @pytest.mark.asyncio async def test_the_user_is_stashed_as_an_opaque_key(self): def info_for(headers): return browsers.request_info( Request( scope={ "type": "http", "method": "GET", "scheme": "http", "server": ("test", 80), "path": "/api/v1/coal_browser", "query_string": b"", "headers": headers, } ) ) alice = (await info_for([(b"x-openbb-user", b"alice@x.com")]))["user"] bob = (await info_for([(b"x-openbb-user", b"bob@x.com")]))["user"] assert alice and bob and alice != bob assert "alice@x.com" not in alice assert (await info_for([]))["user"] == "" @pytest.mark.asyncio async def test_post_body_is_read(self): async def receive(): return {"type": "http.request", "body": b"a=1", "more_body": False} request = Request( scope={ "type": "http", "method": "POST", "scheme": "http", "server": ("test", 80), "path": "/api/v1/eia_proxy/x", "query_string": b"", "headers": [(b"content-type", b"application/json")], }, receive=receive, ) info = await browsers.request_info(request) assert info["method"] == "POST" assert info["body"] == b"a=1" assert info["content_type"] == "application/json" @pytest.mark.asyncio async def test_unreadable_post_body_is_empty(self): async def receive(): raise RuntimeError("disconnected") request = Request( scope={ "type": "http", "method": "POST", "scheme": "http", "server": ("test", 80), "path": "/api/v1/eia_proxy/x", "query_string": b"", "headers": [], }, receive=receive, ) assert (await browsers.request_info(request))["body"] == b"" class TestSplitWidgetParams: """Widget markers are consumed by the proxy, never forwarded upstream.""" def test_splits_markers_and_keeps_the_rest(self): query = ( f"obb_theme=dark&obb_browser={INTERNATIONAL}&obb_user=abc123" "&obb_view=international%2Fdata&obb_seq=1700&frequency=A&pid=44" ) rest, dark, browser, view, seq, user = browsers._split_widget_params(query) assert rest == "frequency=A&pid=44" assert dark is True assert browser == INTERNATIONAL assert view == "international/data" assert seq == 1700.0 assert user == "abc123" def test_light_theme_and_unknown_browser_are_dropped(self): rest, dark, browser, view, seq, user = browsers._split_widget_params( "obb_theme=light&obb_browser=bogus" ) assert rest == "" assert dark is False assert browser == "" assert view == "" assert seq == 0.0 assert user == "" def test_a_malformed_sequence_is_ignored(self): assert browsers._split_widget_params("obb_seq=nope")[4] == 0.0 class TestDeriveBrowser: """Angular SPAs issue untagged requests, so the path must identify the app.""" def test_maps_app_root_to_browser(self): assert browsers._derive_browser("international/api/data/data") == INTERNATIONAL assert ( browsers._derive_browser("states/api/x") == (browsers.EIA_DATA_BROWSERS["states"]["path"]) ) def test_outlooks_keeps_two_segments(self): assert browsers._app_root("outlooks/steo/data/browser/") == "outlooks/steo/" def test_unknown_path_has_no_browser(self): assert browsers._derive_browser("nowhere/x") == "" class TestIsDataResponse: """Only a view's table payload counts as its data.""" @pytest.mark.parametrize( "target", [ "https://www.eia.gov/international/api/data/data?x=1", "https://www.eia.gov/international/api/ranking/latestWorldTop", "https://www.eia.gov/coal/data/browser/data/index.php?x=1", ], ) def test_table_payloads_are_data(self, target): assert browsers.is_data_response(target, "application/json") is True @pytest.mark.parametrize( "target", [ "https://www.eia.gov/international/api/articles/getLatestArticles", "https://www.eia.gov/international/api/countries/data", "https://www.eia.gov/states/api/Projects/States/Json/geo.json", "https://www.eia.gov/global/x.json", "https://www.eia.gov/coal/data/browser/?method=getConfig", ], ) def test_config_and_chrome_payloads_are_not_data(self, target): assert browsers.is_data_response(target, "application/json") is False def test_non_json_is_never_data(self): assert browsers.is_data_response("https://www.eia.gov/x", "text/html") is False def test_ngqs_report_config_and_report_table_are_distinguished(self): base = "https://www.eia.gov/naturalgas/ngqs/data/report" assert browsers.is_data_response(base, "application/json") is False assert browsers.is_data_response(f"{base}/1", "application/json") is True class TestRenderedTable: """``raw`` serves the table the page has rendered -- the current view itself.""" ROWS = [ {"category": "All grades", "2024": None}, {"category": "World", "2024": None}, {"category": "Total U.S.", "2024": 2410516.0}, ] @pytest.mark.asyncio async def test_the_page_publishes_its_table(self): info = make_info( "/api/v1/eia_table", query=f"obb_browser={IMPORTS}&obb_seq=2000", method="POST", body=json.dumps(self.ROWS).encode(), ) response = await browsers.eia_table(info) assert response.status_code == 204 assert browsers.get_table_rows(IMPORTS) == self.ROWS @pytest.mark.asyncio async def test_raw_serves_the_rendered_table_verbatim(self, monkeypatch): browsers.set_table_rows(IMPORTS, self.ROWS, 2000) spec = browsers.EIA_DATA_BROWSERS["petroleum_imports"] assert await browsers.raw_table("petroleum_imports", spec) == self.ROWS @pytest.mark.asyncio async def test_imports_raw_prefers_the_hierarchical_payload(self, monkeypatch): spec = browsers.EIA_DATA_BROWSERS["petroleum_imports"] target = f"{browsers._EIA_ORIGIN}/petroleum/imports/browser/data/index.php?x=1" payload = { "TABLE_DATA": [ { "seriesID": "PET_IMPORTS.WORLD-RP_3-LSO.A", "data": {"2009": 100, "2011": 300, "2010": 200}, } ] } FakeUpstream( { f"{browsers._EIA_ORIGIN}/{browsers._IMPORTS_CONFIG}": ( json.dumps(IMPORTS_CONFIG).encode(), "application/json", ), target: (json.dumps(payload).encode(), "application/json"), } ).install(monkeypatch) browsers._IMPORTS_LABELS.clear() browsers.set_table_rows(IMPORTS, [{"category": "Texas", "2009 ": 1}], 2000) browsers.set_current_view(spec["path"], "imports/view") browsers.set_data_target( spec["path"], "imports/view", {"url": target, "method": "GET"} ) rows = await browsers.raw_table("petroleum_imports", spec) assert rows[0]["category"] == ( "Imports of Light Sour from World to PADD3 (Gulf Coast), annual" ) assert rows[0]["origin"] == "World" assert rows[0]["destination"] == "PADD3 (Gulf Coast)" assert rows[0]["grade"] == "Light Sour" assert list(rows[0])[-3:] == ["2009 ", "2010 ", "2011 "] browsers._IMPORTS_LABELS.clear() @pytest.mark.asyncio async def test_a_later_table_replaces_an_earlier_one(self): browsers.set_table_rows(IMPORTS, self.ROWS, 2000) newer = [{"category": "Light Sweet", "2024": 7.0}] browsers.set_table_rows(IMPORTS, newer, 3000) assert browsers.get_table_rows(IMPORTS) == newer @pytest.mark.asyncio async def test_a_table_from_a_view_the_user_left_is_ignored(self): browsers.set_table_rows(IMPORTS, self.ROWS, 3000) stale = [{"category": "STALE", "2024": 1.0}] browsers.set_table_rows(IMPORTS, stale, 1000) assert browsers.get_table_rows(IMPORTS) == self.ROWS @pytest.mark.asyncio async def test_an_unknown_browser_publishes_nothing(self): await browsers.eia_table( make_info( "/api/v1/eia_table", query="obb_browser=bogus", method="POST", body=json.dumps(self.ROWS).encode(), ) ) assert not browsers._TABLE_ROWS @pytest.mark.asyncio async def test_a_malformed_body_publishes_nothing(self): for body in (b"not json", b"[]", b'["junk"]', b'{"a":1}'): await browsers.eia_table( make_info( "/api/v1/eia_table", query=f"obb_browser={IMPORTS}", method="POST", body=body, ) ) assert not browsers._TABLE_ROWS def test_the_extractor_is_injected_into_the_page(self): page = browsers.rewrite_html("", PROXY_PREFIX, browser=IMPORTS) assert browsers._table_bridge_js(IMPORTS) in page assert "eia:table" in page assert "/eia_table" in page assert "forEachNodeAfterFilterAndSort" in page def test_the_extractor_is_not_injected_without_a_browser(self): page = browsers.rewrite_html("", PROXY_PREFIX) assert "eia:table" not in page @pytest.mark.asyncio async def test_raw_does_not_fall_back_to_the_default_view(self, monkeypatch): spec = browsers.EIA_DATA_BROWSERS["electricity"] browsers.set_table_rows(ELECTRICITY, self.ROWS, 2000) def unused(_target): raise AssertionError("raw must not re-fetch when a table was published") monkeypatch.setattr(browsers, "_fetch_sync", unused) assert await browsers.raw_table("electricity", spec) == self.ROWS NODE = shutil.which("node") or "/usr/local/bin/node" HARNESS = """ var posted = null, sent = null; var listeners = {}; global.window = global; global.location = { pathname: "/api/v1/eia_proxy/electricity/data/browser/", search: "", hash: "", origin: "http://test" }; global.document = { documentElement: {}, querySelectorAll: function () { return []; }, }; global.MutationObserver = function () { return { observe: function () {} }; }; global.fetch = function (url, init) { sent = { url: url, body: init && init.body, keepalive: !!(init && init.keepalive), }; return { catch: function () {} }; }; window.parent = { postMessage: function (msg) { posted = msg; } }; window.addEventListener = function (name, fn) { listeners[name] = fn; }; __BRIDGE__ __GRID__ setTimeout(function () { process.stdout.write(JSON.stringify({ posted: posted, sent: sent }), function () { process.exit(0); }); }, 1600); """ XHR_TAG_HARNESS = """ var beacon = null; global.window = global; global.location = { pathname: "/api/v1/eia_proxy/totalenergy/data/browser/", search: "__SEARCH__", hash: "#/?f=M", origin: "http://test", href: "http://test/api/v1/eia_proxy/totalenergy/data/browser/", }; global.document = { baseURI: location.href }; var store = __STORE__; global.sessionStorage = { getItem: function (k) { return k in store ? store[k] : null; }, setItem: function (k, v) { store[k] = v; }, }; try { global.navigator = { sendBeacon: function (u) { beacon = u; return true; } }; } catch (e) { /* node ships a read-only navigator; the beacon falls back to fetch */ } global.history = { pushState: function () {}, replaceState: function () {} }; global.XMLHttpRequest = function () {}; global.XMLHttpRequest.prototype = { open: function () {} }; global.fetch = function (url) { if (typeof url === "string" && url.indexOf("/eia_view") >= 0) beacon = url; return { catch: function () {} }; }; window.addEventListener = function () {}; __SCRIPT__ process.stdout.write(JSON.stringify({ beacon: beacon, store: store })); """ @pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed") class TestProxyRequestTagger: """The tagger keeps every proxied request bound to its user across reloads.""" def run(self, search, store): script = ( browsers._xhr_tag_js("totalenergy/data/browser/", "") .replace("", "") ) source = ( XHR_TAG_HARNESS.replace("__SEARCH__", search) .replace("__STORE__", json.dumps(store)) .replace("__SCRIPT__", script) ) result = subprocess.run( # noqa: S603 [NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True, ) return json.loads(result.stdout) def test_the_user_key_is_persisted_on_the_first_load(self): out = self.run("?obb_theme=dark&obb_user=HASH123", {}) assert out["store"]["obb_user"] == "HASH123" assert "obb_user=HASH123" in out["beacon"] def test_the_user_key_survives_a_query_clobbering_navigation(self): out = self.run("?tbl=T09.05", {"obb_user": "HASH123"}) assert "obb_user=HASH123" in out["beacon"] @pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed") class TestHostRestore: """The host rebuilds the iframe src from the saved nav, per browser.""" def run(self, browser, src, nav): html = browsers._TEMPLATE.read_text(encoding="utf-8") funcs = html[html.index("var storeKey") : html.index("function reportView")] data = {"browser": browser, "user": "U9", "src": src} store = {f"eia:view:{browser}:U9": json.dumps(nav)} source = ( "global.window = global;\n" f"var _store = {json.dumps(store)};\n" "global.localStorage = { getItem: function (k) { return _store[k] || null; }," " setItem: function () {}, removeItem: function () {} };\n" f"var DATA = {json.dumps(data)};\n" f"{funcs}\n" "process.stdout.write(restoredSrc());" ) return subprocess.run( # noqa: S603 [NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True, ).stdout def test_mer_restores_the_full_state_including_the_range(self): out = self.run( "total_energy", "http://h/api/v1/eia_proxy/totalenergy/data/browser/?obb_theme=dark&obb_user=U9", { "pathname": "/api/v1/eia_proxy/totalenergy/data/browser/", "search": "?tbl=T09.01", "hash": "#/?f=M&start=201506&end=202604&charted=1-2", }, ) assert out.endswith( "/totalenergy/data/browser/?obb_theme=dark&obb_user=U9&tbl=T09.01" "#/?f=M&start=201506&end=202604&charted=1-2" ) def test_other_browsers_restore_the_full_hash(self): out = self.run( "electricity", "http://h/api/v1/eia_proxy/electricity/data/browser/?obb_theme=dark&obb_user=U9", { "pathname": "/api/v1/eia_proxy/electricity/data/browser/", "search": "", "hash": "#/topic/0?agg=2,0,1&fuel=vtvv", }, ) assert out.endswith("#/topic/0?agg=2,0,1&fuel=vtvv") def test_the_frame_is_nudged_to_repaint_the_grid(self): html = browsers._TEMPLATE.read_text(encoding="utf-8") nudge = html[html.index("function nudge()") : html.index("function ready()")] source = ( "var dispatched = [];\n" "global.Event = function (t) { this.type = t; };\n" "var frame = { contentWindow: { dispatchEvent: function (e) {" " dispatched.push(e.type); } } };\n" "global.setTimeout = function (fn) { fn(); };\n" f"{nudge}\n" "nudge();\n" "process.stdout.write(JSON.stringify({ dispatched: dispatched }));" ) out = json.loads( subprocess.run( # noqa: S603 [NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True, ).stdout ) assert out["dispatched"] == ["resize", "resize", "resize", "resize"] @pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed") class TestSlickGridExtraction: """The classic browsers draw with SlickGrid, and raw must read what they drew.""" GRID = """ function resolver(field, datum) { var path = field.split('.'); var ret = datum; for (var i = 0; i < path.length; i++) { ret = ret[path[i]]; if (!ret || typeof ret == 'undefined') break; } return ret; } var descriptionOutputFormatter = function (row, cell, value, columnDef, datum) { return datum.CHART_NAME || value; }; var numericalDataFormatter = function (row, cell, value, columnDef, datum) { if (!datum.HAS_DATA) return ''; var ret = isNaN(parseFloat(value)) ? value : Number(value).toFixed(datum.PRECISION !== undefined ? datum.PRECISION : 0) .replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); return ret === undefined || ret === '' || ret === null ? '--' : ret; }; var COLUMNS = __COLUMNS__; for (var i = 0; i < COLUMNS.length; i++) { if (COLUMNS[i].outputFormatter === '@description') COLUMNS[i].outputFormatter = descriptionOutputFormatter; if (COLUMNS[i].dataFormatter === '@numerical') COLUMNS[i].dataFormatter = numericalDataFormatter; } var OPTIONS = { frozenColumn: 2, addSpacerColumn: true, dataItemColumnValueExtractor: function (item, colDef) { if (colDef && colDef.field) return resolver(colDef.field, item); return null; } }; function Grid(container, data, columns, options) { this.data = data; this.columns = columns; this.options = options; this.onRendered = { subscribe: function () {} }; } Grid.prototype.getColumns = function () { return this.columns; }; Grid.prototype.getOptions = function () { return this.options; }; Grid.prototype.getDataLength = function () { return this.data.length; }; Grid.prototype.getDataItem = function (i) { return this.data[i]; }; window.Slick = { Grid: Grid }; setTimeout(function () { new window.Slick.Grid("#g", __DATA__, COLUMNS, OPTIONS); }, __DELAY__); """ COLUMNS = [ {"id": "pinKey", "field": "HAS_DATA", "output": False}, { "id": "description", "field": "DESCRIPTION", "outputFormatter": "@description", }, {"id": "chart", "field": "HAS_DATA", "output": False}, {"id": "units", "name": "units", "field": "UNITS", "display": False}, { "id": "source key", "name": "source key", "field": "SERIES_ID", "display": False, }, { "id": "200101", "name": "Jan 2001", "field": "DATA.200101", "rseField": "RSE_DATA.200101", "dataFormatter": "@numerical", }, {"id": "spacer", "name": " ", "autoWidth": True}, ] DATA = [ { "DESCRIPTION": "United States", "LEVEL": 0, "CHART_NAME": "United States", "SERIES_ID": "ELEC.GEN..M", "HAS_DATA": False, "PRECISION": 0, "DATA": {"200101": "--"}, }, { "DESCRIPTION": "All fuels", "LEVEL": 2, "CHART_NAME": "United States : all fuels (utility-scale)", "SERIES_ID": "ELEC.GEN.ALL-US-99.M", "HAS_DATA": True, "PRECISION": 0, "UNITS": "thousand megawatthours", "DATA": {"200101": 332493.16}, }, { "DESCRIPTION": "Coal", "LEVEL": 3, "CHART_NAME": "United States : coal", "SERIES_ID": "ELEC.GEN.COW-US-99.M", "HAS_DATA": True, "PRECISION": 0, "UNITS": "thousand megawatthours", "DATA": {"200101": 177287.111}, }, ] ANNUAL_COLUMNS = [ {"id": "pinKey", "field": "HAS_DATA", "output": False}, { "id": "description", "field": "DESCRIPTION", "outputFormatter": "@description", }, ] + [ { "id": str(year), "name": str(year), "field": f"DATA.{year}", "dataFormatter": "@numerical", } for year in (2014, 2015, 2009, 2010) ] ANNUAL_DATA = [ { "DESCRIPTION": "Total U.S.", "LEVEL": 0, "CHART_NAME": "Total U.S.", "SERIES_ID": "PET_IMPORTS.WORLD-US-ALL.A", "HAS_DATA": True, "PRECISION": 0, "UNITS": "thousand barrels", "DATA": {"2009": 100, "2010": 200, "2014": 239554, "2015": 244173}, }, ] def run(self, data=None, columns=None, delay=120): script = browsers._table_bridge_js(ELECTRICITY) bridge = script.replace("", "") grid = ( self.GRID.replace( "__DATA__", json.dumps(self.DATA if data is None else data) ) .replace( "__COLUMNS__", json.dumps(self.COLUMNS if columns is None else columns), ) .replace("__DELAY__", str(delay)) ) source = HARNESS.replace("__BRIDGE__", bridge).replace("__GRID__", grid) with tempfile.TemporaryDirectory() as tmp: script_path = Path(tmp) / "harness.js" script_path.write_text(source, encoding="utf-8") result = subprocess.run( # noqa: S603 [NODE, str(script_path)], capture_output=True, text=True, timeout=30, check=True, ) return json.loads(result.stdout) def test_the_rendered_grid_is_published_to_the_shell(self): posted = self.run()["posted"] assert posted["type"] == "eia:table" assert posted["rows"] == [ {"category": "United States", "Jan 2001": None}, { "category": "United States : all fuels (utility-scale)", "Jan 2001": 332493.16, }, {"category": "United States : coal", "Jan 2001": 177287.111}, ] def test_the_same_rows_are_posted_to_the_backend(self): result = self.run() assert result["sent"]["url"].startswith("/api/v1/eia_table?obb_browser=") assert json.loads(result["sent"]["body"]) == result["posted"]["rows"] def test_a_period_value_is_read_through_its_dotted_field_path(self): posted = self.run()["posted"] assert [row["Jan 2001"] for row in posted["rows"]] == [ None, 332493.16, 177287.111, ] def test_full_precision_survives_the_grids_own_formatter(self): posted = self.run()["posted"] assert posted["rows"][2]["Jan 2001"] == 177287.111 def test_the_hierarchy_is_the_one_the_grid_renders(self): posted = self.run()["posted"] assert [row["category"] for row in posted["rows"]] == [ "United States", "United States : all fuels (utility-scale)", "United States : coal", ] def test_only_the_columns_on_screen_are_published(self): posted = self.run()["posted"] for row in posted["rows"]: assert list(row) == ["category", "Jan 2001"] def test_an_annual_table_keeps_its_label_column_first(self): result = self.run(data=self.ANNUAL_DATA, columns=self.ANNUAL_COLUMNS) rows = json.loads(result["sent"]["body"]) assert list(rows[0]) == ["category", "2009 ", "2010 ", "2014 ", "2015 "] assert rows[0]["category"] == "Total U.S." assert rows[0]["2014 "] == 239554 def test_icon_button_columns_are_dropped(self): columns = [ { "id": "sourcekey", "field": "HAS_DATA", "output": False, "iconClass": "sourcekey", }, { "id": "remove", "name": "", "field": "DESCRIPTION", "output": True, "iconClass": "remove", }, { "id": "description", "name": "", "field": "DESCRIPTION", "outputFormatter": "@description", }, {"id": "map", "field": "HAS_DATA", "output": True, "iconClass": "map"}, { "id": "linechart", "field": "HAS_DATA", "output": True, "iconClass": "chart", }, { "id": "200101", "name": "Jan 2001", "field": "DATA.200101", "dataFormatter": "@numerical", }, ] posted = self.run(columns=columns)["posted"] for row in posted["rows"]: assert list(row) == ["category", "Jan 2001"] def test_a_row_with_no_data_yields_nulls_not_the_dash(self): posted = self.run()["posted"] assert posted["rows"][0]["Jan 2001"] is None def test_the_post_does_not_use_keepalive(self): assert self.run()["sent"]["keepalive"] is False def test_a_table_over_the_keepalive_limit_is_posted_whole(self): columns = ( self.COLUMNS[:5] + [ { "id": str(200101 + n), "name": f"period {n}", "field": f"DATA.{200101 + n}", "dataFormatter": "@numerical", } for n in range(305) ] + [self.COLUMNS[-1]] ) data = [ { "DESCRIPTION": f"Fuel {r}", "CHART_NAME": f"United States : fuel {r}", "SERIES_ID": f"ELEC.GEN.F{r}-US-99.M", "HAS_DATA": True, "PRECISION": 0, "DATA": {str(200101 + n): 177287.111 + n for n in range(305)}, } for r in range(21) ] sent = self.run(data=data, columns=columns)["sent"] assert len(sent["body"]) > 64 * 1024 rows = json.loads(sent["body"]) assert len(rows) == 21 assert rows[0]["period 0"] == 177287.111 def test_a_grid_with_no_rows_publishes_nothing(self): assert self.run(data=[])["posted"] is None def test_a_grid_built_much_later_is_still_captured(self): assert self.run(delay=500)["posted"]["rows"] def test_the_page_is_left_holding_a_working_grid(self): source = HARNESS.replace( "__BRIDGE__", browsers._table_bridge_js(ELECTRICITY) .replace("", ""), ).replace( "__GRID__", self.GRID.replace("__DATA__", json.dumps(self.DATA)) .replace("__COLUMNS__", json.dumps(self.COLUMNS)) .replace("__DELAY__", "120") .replace( 'new window.Slick.Grid("#g", ', 'var g = new window.Slick.Grid("#g", ', ) .replace( "}, 120);", " if (!(g instanceof window.Slick.Grid)) throw new Error('broke instanceof');" " if (g.getDataLength() !== 3) throw new Error('broke the grid');" "}, 120);", ), ) subprocess.run( # noqa: S603 [NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True ) LIFECYCLE_GRID = """ var DOC = { contains: function (n) { return n._attached; } }; function makeNode(attached, visible) { var n = { _attached: attached, _viewport: true, ownerDocument: DOC, querySelector: function (sel) { return (n._viewport && sel === '.slick-viewport') ? {} : null; }, getClientRects: function () { return visible ? [{}] : []; }, offsetParent: visible ? {} : null, }; return n; } var COLUMNS = [ { id: 'description', field: 'DESCRIPTION' }, { id: '200101', name: 'Jan 2001', field: 'DATA.200101' }, ]; var OPTIONS = { dataItemColumnValueExtractor: function (item, colDef) { var path = colDef.field.split('.'), ret = item; for (var i = 0; i < path.length; i++) ret = ret[path[i]]; return ret; }, }; function Grid(container, data, columns, options) { this.node = container; this.data = data; this.columns = columns; this.options = options; this.onRendered = { subscribe: function () {} }; } Grid.prototype.getColumns = function () { return this.columns; }; Grid.prototype.getOptions = function () { return this.options; }; Grid.prototype.getDataLength = function () { return this.data.length; }; Grid.prototype.getDataItem = function (i) { return this.data[i]; }; Grid.prototype.getContainerNode = function () { return this.node; }; Grid.prototype.destroy = function () { this.node._viewport = false; }; window.Slick = { Grid: Grid }; setTimeout(function () { var stale = new window.Slick.Grid(makeNode(true, true), __STALE__, COLUMNS, OPTIONS); new window.Slick.Grid(makeNode(true, true), __CURRENT__, COLUMNS, OPTIONS); __TEARDOWN__ }, 120); """ def _rows(label, count): """Build ``count`` grid rows carrying ``label`` as their description.""" return [ {"DESCRIPTION": f"{label} {n}", "DATA": {"200101": float(n)}} for n in range(count) ] @pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed") class TestGridLifecycle: """A view the user left must never supply the table ``raw`` answers with. EIA rebuilds the grid per view and calls ``destroy()`` on the old one, which only empties the container -- the instance keeps answering ``getDataLength()`` with the row count of the view it drew. A stale grid is routinely larger than the current one, so picking by row count alone hands ``raw`` the table of a view that is no longer on screen. """ def run(self, stale, current, teardown): script = browsers._table_bridge_js(ELECTRICITY) bridge = script.replace("", "") grid = ( LIFECYCLE_GRID.replace("__STALE__", json.dumps(stale)) .replace("__CURRENT__", json.dumps(current)) .replace("__TEARDOWN__", teardown) ) source = HARNESS.replace("__BRIDGE__", bridge).replace("__GRID__", grid) result = subprocess.run( # noqa: S603 [NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True ) return json.loads(result.stdout) def test_a_destroyed_grid_does_not_supply_the_table(self): posted = self.run(_rows("STALE", 6), _rows("CURRENT", 2), "stale.destroy();") assert [row["category"] for row in posted["posted"]["rows"]] == [ "CURRENT 0", "CURRENT 1", ] def test_a_detached_grid_does_not_supply_the_table(self): posted = self.run( _rows("STALE", 6), _rows("CURRENT", 2), "stale.node._attached = false;" ) assert [row["category"] for row in posted["posted"]["rows"]] == [ "CURRENT 0", "CURRENT 1", ] def test_a_hidden_grid_does_not_supply_the_table(self): posted = self.run( _rows("STALE", 6), _rows("CURRENT", 2), "stale.node.offsetParent = null; stale.node.getClientRects = " "function () { return []; };", ) assert [row["category"] for row in posted["posted"]["rows"]] == [ "CURRENT 0", "CURRENT 1", ] def test_the_larger_live_grid_still_wins(self): posted = self.run(_rows("STALE", 6), _rows("CURRENT", 2), "") assert len(posted["posted"]["rows"]) == 6 def test_the_scrape_names_the_view_it_was_taken_from(self): sent = self.run(_rows("STALE", 6), _rows("CURRENT", 2), "stale.destroy();")[ "sent" ] assert "obb_view=electricity%2Fdata%2Fbrowser%2F" in sent["url"] SYNC_HARNESS = """ var intervals = [], listeners = {}; global.window = global; var select = { tagName: 'SELECT', value: '-1', options: [ { value: '-1', text: 'Select a view' }, { value: '0', text: 'Imports of all grades to US' }, { value: '8', text: 'Top 10 light crude oil importing refineries in 2013' }, ], }; global.location = { pathname: '/api/v1/eia_proxy/petroleum/imports/browser/', hash: '#/?vs=PET_IMPORTS.WORLD-US-ALL.A', }; global.document = { getElementsByTagName: function (tag) { return tag === 'select' ? [select] : []; }, }; global.window.addEventListener = function (name, fn) { listeners[name] = fn; }; global.setInterval = function (fn) { intervals.push(fn); return intervals.length; }; var CONFIG = __CONFIG__; global.fetch = function () { return Promise.resolve({ json: function () { return Promise.resolve(CONFIG); } }); }; __SCRIPT__ function tick() { for (var i = 0; i < intervals.length; i++) intervals[i](); } setTimeout(function () { __DRIVER__ process.stdout.write(JSON.stringify({ value: select.value })); }, 80); """ V0_VS = "PET_IMPORTS.WORLD-US-ALL.A" V8_VS = "PET_IMPORTS.WORLD-RF_465-O.A~PET_IMPORTS.WORLD-RF_225-O.A" CONFIG_WITH_VIEWS = { "views": [ {"id": "0", "json": json.dumps({"vs": V0_VS})}, { "id": "8", "json": json.dumps({"columnendpoints": "2", "d": "abc", "vs": V8_VS}), }, ] } CONFIG_NO_VIEWS = {"status": "OK"} @pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed") class TestFeaturedViewSync: """The Featured views select must mirror the view encoded in the live hash.""" def run(self, driver, config=None): script = browsers._VIEW_SELECT_SYNC_JS.replace("", "" ) source = ( SYNC_HARNESS.replace( "__CONFIG__", json.dumps(CONFIG_WITH_VIEWS if config is None else config), ) .replace("__SCRIPT__", script) .replace("__DRIVER__", driver) ) result = subprocess.run( # noqa: S603 [NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True, ) return json.loads(result.stdout) def test_the_default_view_is_reflected_on_load(self): assert self.run("")["value"] == "0" def test_a_view_in_the_hash_is_reflected(self): driver = ( f"location.hash = '#/?d=xyz&f=a&vs=' + encodeURIComponent('{V8_VS}');" "tick();" ) assert self.run(driver)["value"] == "8" def test_matching_ignores_the_other_view_params(self): driver = ( f"location.hash = '#/?vs=' + encodeURIComponent('{V8_VS}') + '&d=DIFF';" "tick();" ) assert self.run(driver)["value"] == "8" def test_a_hashchange_event_drives_the_sync(self): driver = ( f"location.hash = '#/?vs=' + encodeURIComponent('{V8_VS}');" "listeners['hashchange']();" ) assert self.run(driver)["value"] == "8" def test_an_unmatched_state_clears_to_the_placeholder(self): driver = "location.hash = '#/?vs=PET_IMPORTS.NO_SUCH_SERIES.A';tick();" assert self.run(driver)["value"] == "-1" def test_a_browser_without_view_config_is_left_untouched(self): driver = "select.value = '0';tick();" assert self.run(driver, config=CONFIG_NO_VIEWS)["value"] == "0" def test_the_script_is_injected_into_the_classic_browser_page(self): page = browsers.rewrite_html( "", PROXY_PREFIX, browser=IMPORTS, ) assert browsers._VIEW_SELECT_SYNC_JS in page class TestText: def test_strips_tags_and_unescapes(self): assert browsers._text("A &\n B") == "A & B" class TestParseMapsPage: def test_groups_formats_by_document(self): entries = browsers.parse_maps_page(MAPS_HTML) assert len(entries) == 4 shale = entries[0] assert shale["tab"] == "Recent maps" assert shale["section"] == "Overview & boundaries" assert shale["title"] == "Shale plays, Lower 48 States" assert shale["date"] == "12/13/2023" assert shale["pdf"] == "/maps/images/shale_2023.pdf" assert shale["image"] == "/maps/images/shale_2023.jpg" def test_title_without_date_strips_format_suffix(self): assert browsers.parse_maps_page(MAPS_HTML)[1] == { "tab": "Recent maps", "section": "Overview & boundaries", "title": "Major tight gas plays", "date": "", "pdf": "/maps/images/tight_gas.pdf", } def test_second_tab_and_image_only(self): assert browsers.parse_maps_page(MAPS_HTML)[2] == { "tab": "Historical maps", "section": "Historical basins", "title": "Historic basin map", "date": "6/1/2011", "image": "/maps/images/old_map.jpg", } def test_section_prefix_stripped_from_title(self): assert browsers.parse_maps_page(MAPS_HTML)[3] == { "tab": "Historical maps", "section": "Historical basins", "title": "Western Area BOE", "date": "", "pdf": "/maps/images/field_maps/app_boe.pdf", } def test_unsupported_format_skipped(self): assert not any( entry.get("pdf", "").endswith(".zip") for entry in browsers.parse_maps_page(MAPS_HTML) ) def test_anchor_outside_tabs_skipped(self): html = 'PDF' assert browsers.parse_maps_page(html) == [] def test_more_tabs_than_labels_get_placeholder(self): html = ( '
' 'PDF
' ) assert browsers.parse_maps_page(html)[0]["tab"] == "Maps 1" class TestFetchMapsCatalog: @pytest.mark.asyncio async def test_fetches_parses_and_caches(self, monkeypatch): target = f"{browsers._EIA_ORIGIN}/{browsers.MAPS_PAGE}" upstream = FakeUpstream({target: (MAPS_HTML.encode(), "text/html")}) upstream.install(monkeypatch) first = await browsers.fetch_maps_catalog() second = await browsers.fetch_maps_catalog() assert first is second assert len(upstream.calls) == 1 assert first[0]["title"] == "Shale plays, Lower 48 States" class TestRewriteHtml: """Injected CSS/JS must hide EIA's site chrome without eating its apps.""" def test_rewrites_root_paths_and_injects_into_head(self): html = 'x' out = browsers.rewrite_html(html, PROXY_PREFIX) assert f'"{PROXY_PREFIX}/coal/"' in out assert f'"{PROXY_PREFIX}/img.png"' in out assert out.index("