"""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 = """
Recent maps
Historical maps
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 = (
''
)
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("