Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend/tests/test_core/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Tests for the Settings secret guard and CORS parsing."""

import pytest

from ref_backend.core.config import Settings, parse_cors


class TestDefaultSecretGuard:
"""Test that a default placeholder secret is refused outside local development."""

def test_default_secret_raises_outside_local(self):
"""A default SECRET_KEY in a non-local environment raises ValueError."""
with pytest.raises(ValueError, match="changethis"):
Settings(_env_file=None, SECRET_KEY="changethis", ENVIRONMENT="staging") # noqa: S106

def test_default_secret_warns_in_local(self):
"""A default SECRET_KEY in the local environment warns but still constructs."""
with pytest.warns(UserWarning, match="changethis"):
settings = Settings(_env_file=None, SECRET_KEY="changethis", ENVIRONMENT="local") # noqa: S106
assert settings.SECRET_KEY == "changethis" # noqa: S105

def test_non_default_secret_constructs_without_warning(self, recwarn):
"""A non-default SECRET_KEY constructs cleanly in staging with no warning."""
settings = Settings(_env_file=None, SECRET_KEY="a-real-secret-value", ENVIRONMENT="staging") # noqa: S106
assert settings.SECRET_KEY == "a-real-secret-value" # noqa: S105
assert len(recwarn) == 0


class TestParseCors:
"""Test the parse_cors BeforeValidator."""

def test_splits_comma_separated_string(self):
"""A comma-separated string is split into a list of trimmed origins."""
result = parse_cors("http://a.example, http://b.example")
assert result == ["http://a.example", "http://b.example"]

def test_list_passes_through(self):
"""A list value passes through unchanged."""
result = parse_cors(["http://a.example", "http://b.example"])
assert result == ["http://a.example", "http://b.example"]
27 changes: 27 additions & 0 deletions backend/tests/test_core/test_file_handling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Tests for chunked file reading used by the log-download endpoint."""

from ref_backend.core.file_handling import file_iterator


class TestFileIterator:
"""Test the file_iterator generator."""

def test_reassembles_full_content(self, tmp_path):
"""Chunks concatenate back into the original file content."""
content = b"hello world, this is some file content"
file_path = tmp_path / "sample.txt"
file_path.write_bytes(content)

chunks = list(file_iterator(str(file_path)))
assert b"".join(chunks) == content

def test_chunk_size_respected(self, tmp_path):
"""A file larger than one chunk yields multiple chunks, each within the chunk size."""
content = b"x" * 2500
file_path = tmp_path / "large.txt"
file_path.write_bytes(content)

chunks = list(file_iterator(str(file_path), chunk_size=1024))
assert len(chunks) == 3
assert [len(c) for c in chunks] == [1024, 1024, 452]
assert b"".join(chunks) == content
181 changes: 181 additions & 0 deletions backend/tests/test_core/test_reader_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Tests for the live reader-values CSV generation and dimension-filter helpers."""

import asyncio
import math

from starlette.responses import StreamingResponse

from climate_ref import models
from climate_ref.results.values import (
ScalarValue,
ScalarValueCollection,
SeriesValue,
SeriesValueCollection,
)
from ref_backend.core.reader_values import (
generate_csv_response_scalar,
generate_csv_response_series,
parse_dimension_filters,
)


async def _collect_body(response: StreamingResponse) -> str:
chunks = [chunk.decode() if isinstance(chunk, bytes) else chunk async for chunk in response.body_iterator]
return "".join(chunks)


def _read_body(response: StreamingResponse) -> str:
return asyncio.run(_collect_body(response))


def _scalar(**overrides):
defaults = {
"id": 1,
"execution_id": 1,
"execution_group_id": 1,
"value": 1.0,
"kind": "model",
"dimensions": {"source_id": "A", "variable_id": "tas"},
"attributes": {},
}
defaults.update(overrides)
return ScalarValue(**defaults)


def _scalar_collection(items, **overrides):
defaults = {
"items": tuple(items),
"total_count": len(items),
"facets": (),
"offset": 0,
"limit": None,
"had_outliers": False,
"outlier_count": 0,
}
defaults.update(overrides)
return ScalarValueCollection(**defaults)


def _series(**overrides):
defaults = {
"id": 1,
"execution_id": 1,
"execution_group_id": 1,
"values": [1.0, 2.0, 3.0],
"index": [2000, 2001, 2002],
"index_name": "year",
"reference_id": None,
"kind": "model",
"dimensions": {"source_id": "A"},
"attributes": {},
}
defaults.update(overrides)
return SeriesValue(**defaults)


def _series_collection(items, **overrides):
defaults = {
"items": tuple(items),
"total_count": len(items),
"facets": (),
"offset": 0,
"limit": None,
}
defaults.update(overrides)
return SeriesValueCollection(**defaults)


class TestParseDimensionFilters:
"""Test parse_dimension_filters against the registered CV dimensions."""

def test_keeps_registered_dimension(self, app):
"""A registered CV dimension is kept."""
cv_dimension = sorted(models.ScalarMetricValue._cv_dimensions)[0]
result = parse_dimension_filters({cv_dimension: "value"})
assert result == {cv_dimension: "value"}

def test_drops_unregistered_key(self, app):
"""An unregistered query parameter is silently dropped."""
cv_dimension = sorted(models.ScalarMetricValue._cv_dimensions)[0]
result = parse_dimension_filters({cv_dimension: "value", "not_a_cv_dimension": "x"})
assert result == {cv_dimension: "value"}


class TestGenerateCsvResponseScalar:
"""Test the scalar CSV streaming response."""

def test_header_without_detection(self):
"""Header is sorted dimensions then value, type, with no outlier columns or headers."""
item = _scalar(dimensions={"variable_id": "tas", "source_id": "A"})
collection = _scalar_collection([item])
response = generate_csv_response_scalar(collection, detection_ran=False, filename="out.csv")

body = _read_body(response)
header = body.splitlines()[0]
assert header == "source_id,variable_id,value,type"
assert "X-REF-Had-Outliers" not in response.headers
assert "X-REF-Outlier-Count" not in response.headers
assert response.headers["Content-Disposition"] == "attachment; filename=out.csv"

def test_header_with_detection(self):
"""Header gains outlier columns and X-REF-* headers reflect the collection."""
item = _scalar(is_outlier=True, verification_status="unverified")
collection = _scalar_collection([item], had_outliers=True, outlier_count=3)
response = generate_csv_response_scalar(collection, detection_ran=True, filename="out.csv")

body = _read_body(response)
header = body.splitlines()[0]
assert header == "source_id,variable_id,value,type,is_outlier,verification_status"
assert response.headers["X-REF-Had-Outliers"] == "true"
assert response.headers["X-REF-Outlier-Count"] == "3"

def test_empty_collection_yields_empty_body(self):
"""An empty collection produces an empty body."""
collection = _scalar_collection([])
response = generate_csv_response_scalar(collection, detection_ran=False, filename="out.csv")
assert _read_body(response) == ""

def test_nan_value_is_sanitized_to_empty_cell(self):
"""A NaN value renders as an empty CSV cell rather than the literal 'nan'."""
item = _scalar(value=math.nan)
collection = _scalar_collection([item])
response = generate_csv_response_scalar(collection, detection_ran=False, filename="out.csv")

body = _read_body(response)
data_row = body.splitlines()[1]
# dimensions,,type -> value cell is empty between the two commas around it
assert ",,scalar" in data_row


class TestGenerateCsvResponseSeries:
"""Test the series CSV streaming response."""

def test_per_series_header_and_rows(self):
"""Each series gets its own header block and one row per index point."""
item = _series(values=[1.0, 2.0], index=[2000, 2001], index_name="year")
collection = _series_collection([item])
response = generate_csv_response_series(collection, filename="out.csv")

body = _read_body(response)
lines = body.splitlines()
assert lines[0] == "source_id,value,index,index_name,type"
assert lines[1] == "A,1.0,2000,year,series"
assert lines[2] == "A,2.0,2001,year,series"

def test_index_falls_back_to_ordinal(self):
"""When index is missing, the row uses the ordinal position instead."""
item = _series(values=[10.0, 20.0], index=None, index_name=None)
collection = _series_collection([item])
response = generate_csv_response_series(collection, filename="out.csv")

body = _read_body(response)
lines = body.splitlines()
assert lines[0] == "source_id,value,index,index_name,type"
assert lines[1] == "A,10.0,0,index,series"
assert lines[2] == "A,20.0,1,index,series"

def test_empty_collection_yields_empty_body(self):
"""An empty collection produces an empty body."""
collection = _series_collection([])
response = generate_csv_response_series(collection, filename="out.csv")
assert _read_body(response) == ""
1 change: 1 addition & 0 deletions changelog/43.trivial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adds direct tests for the reader-values CSV path, file streaming, and config validation.
Loading