mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 19:49:14 +09:00
QueryParseError is now a ValidationError subclass — the global handler answers it as a 400 and per-route translations become unnecessary (the existing ones keep working). PageQuery parses, defaults, and bounds page/per_page in one place; admin audit-logs, activity feed, error logs, and telemetry events adopt it (telemetry keeps its 200 cap). git.py and source_connections.py keep hand-parsed params deliberately — they proxy pagination to Gitea/GitHub/GitLab/Bitbucket, not to DB queries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
299 lines
10 KiB
Python
299 lines
10 KiB
Python
"""Tests for the shared $select/$filter/$orderby/$skip/$top layer."""
|
|
|
|
import pytest
|
|
|
|
from app.api._query import (
|
|
ListQuery, QueryParseError, apply_query, parse_filter, parse_orderby,
|
|
parse_select,
|
|
)
|
|
from app.models import Application
|
|
|
|
|
|
class FakeRequest:
|
|
def __init__(self, **args):
|
|
self.args = args
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_app(app):
|
|
"""One persisted Application — to_dict needs a real row to walk."""
|
|
from app import db
|
|
from app.models import User
|
|
from werkzeug.security import generate_password_hash
|
|
with app.app_context():
|
|
# applications.user_id is NOT NULL, so the row needs an owner.
|
|
owner = User.query.first() or User(
|
|
email='probe@t.local', username='probe',
|
|
password_hash=generate_password_hash('x'), role='admin', is_active=True)
|
|
if owner.id is None:
|
|
db.session.add(owner)
|
|
db.session.commit()
|
|
row = Application(name='probe-app', app_type='static', status='stopped',
|
|
user_id=owner.id)
|
|
db.session.add(row)
|
|
db.session.commit()
|
|
db.session.refresh(row)
|
|
yield row
|
|
|
|
|
|
# ---------------------------------------------------------------- $filter
|
|
|
|
def test_filter_none_when_absent(app):
|
|
with app.app_context():
|
|
assert parse_filter('', Application) is None
|
|
assert parse_filter(None, Application) is None
|
|
|
|
|
|
def test_filter_comparison_operators(app):
|
|
with app.app_context():
|
|
for op in ('eq', 'ne', 'gt', 'ge', 'lt', 'le'):
|
|
assert parse_filter(f"name {op} 'demo'", Application) is not None
|
|
|
|
|
|
def test_filter_rejects_unknown_field(app):
|
|
"""The mapped-column set is the allowlist — not a getattr into the model."""
|
|
with app.app_context():
|
|
with pytest.raises(QueryParseError):
|
|
parse_filter("query eq 'x'", Application)
|
|
with pytest.raises(QueryParseError):
|
|
parse_filter("__class__ eq 'x'", Application)
|
|
|
|
|
|
def test_filter_string_functions(app):
|
|
with app.app_context():
|
|
for expr in ("contains(name, 'ap')", "startswith(name, 'a')",
|
|
"endswith(name, 'p')", 'notempty(name)'):
|
|
assert parse_filter(expr, Application) is not None
|
|
|
|
|
|
def test_filter_date_helpers(app):
|
|
with app.app_context():
|
|
assert parse_filter("date(created_at, '2026-01-02')", Application) is not None
|
|
assert parse_filter('daysAgo(created_at, 7)', Application) is not None
|
|
with pytest.raises(QueryParseError):
|
|
parse_filter("date(created_at, 'nope')", Application)
|
|
with pytest.raises(QueryParseError):
|
|
parse_filter('daysAgo(created_at, soon)', Application)
|
|
|
|
|
|
def test_filter_refuses_mixed_combiners(app):
|
|
"""`a and b or c` has no unambiguous reading; guessing one is worse."""
|
|
with app.app_context():
|
|
with pytest.raises(QueryParseError):
|
|
parse_filter("name eq 'a' and status eq 'b' or port eq 1", Application)
|
|
|
|
|
|
def test_filter_malformed_condition(app):
|
|
with app.app_context():
|
|
with pytest.raises(QueryParseError):
|
|
parse_filter('name', Application)
|
|
|
|
|
|
# --------------------------------------------------------------- $orderby
|
|
|
|
def test_orderby_defaults_to_asc_and_accepts_desc(app):
|
|
with app.app_context():
|
|
assert parse_orderby('name', Application)
|
|
assert len(parse_orderby('name asc, created_at desc', Application)) == 2
|
|
with pytest.raises(QueryParseError):
|
|
parse_orderby('name sideways', Application)
|
|
with pytest.raises(QueryParseError):
|
|
parse_orderby('nope asc', Application)
|
|
|
|
|
|
# ---------------------------------------------------------------- $select
|
|
|
|
def test_select_none_means_everything(app):
|
|
with app.app_context():
|
|
assert parse_select('', Application) is None
|
|
|
|
|
|
def test_select_always_includes_the_primary_key(app):
|
|
"""A row the client cannot identify is not useful; every UI keys on id."""
|
|
with app.app_context():
|
|
assert parse_select('name', Application) == {'name', 'id'}
|
|
|
|
|
|
def test_select_accepts_declared_derived_fields(app):
|
|
with app.app_context():
|
|
fields = parse_select('name,project_name,image_scan', Application,
|
|
extra=Application.DERIVED_FIELDS)
|
|
assert {'name', 'project_name', 'image_scan', 'id'} == fields
|
|
|
|
|
|
def test_select_rejects_underived_unknown_field(app):
|
|
with app.app_context():
|
|
with pytest.raises(QueryParseError):
|
|
parse_select('name,not_a_field', Application)
|
|
|
|
|
|
# ------------------------------------------------------------- apply_query
|
|
|
|
def test_list_query_parses_and_caps_paging():
|
|
parsed = ListQuery.from_args({
|
|
'$select': 'id,name',
|
|
'$filter': "name eq 'demo'",
|
|
'$orderby': 'name desc',
|
|
'$skip': '4',
|
|
'$top': '99999',
|
|
})
|
|
|
|
assert parsed == ListQuery(
|
|
select='id,name',
|
|
filter="name eq 'demo'",
|
|
orderby='name desc',
|
|
skip=4,
|
|
top=500,
|
|
)
|
|
|
|
|
|
def test_list_query_rejects_invalid_paging_before_touching_orm():
|
|
with pytest.raises(QueryParseError):
|
|
ListQuery.from_args({'$top': 'many'})
|
|
with pytest.raises(QueryParseError):
|
|
ListQuery.from_args({'$skip': '-1'})
|
|
|
|
def test_apply_query_is_a_noop_without_params(app):
|
|
"""Adoption must not change any existing caller's response."""
|
|
with app.app_context():
|
|
result = apply_query(Application.query, Application, FakeRequest())
|
|
assert result.fields is None
|
|
assert result.total is None
|
|
assert result.top is None
|
|
|
|
|
|
def test_apply_query_caps_top(app):
|
|
with app.app_context():
|
|
result = apply_query(Application.query, Application, FakeRequest(**{'$top': '99999'}))
|
|
assert result.top == 500
|
|
|
|
|
|
def test_apply_query_rejects_non_integer_paging(app):
|
|
with app.app_context():
|
|
with pytest.raises(QueryParseError):
|
|
apply_query(Application.query, Application, FakeRequest(**{'$top': 'ten'}))
|
|
with pytest.raises(QueryParseError):
|
|
apply_query(Application.query, Application, FakeRequest(**{'$skip': '-1'}))
|
|
|
|
|
|
def test_apply_query_counts_before_paging(app):
|
|
"""`total` must be the unpaged count, or "12 of 340" reads "12 of 12"."""
|
|
with app.app_context():
|
|
result = apply_query(Application.query, Application, FakeRequest(**{'$top': '1'}))
|
|
assert result.total is not None
|
|
assert result.top == 1
|
|
|
|
|
|
def test_apply_query_accepts_typed_query_without_flask_request(app):
|
|
with app.app_context():
|
|
result = apply_query(
|
|
Application.query,
|
|
Application,
|
|
ListQuery(orderby='name desc', top=1),
|
|
)
|
|
|
|
assert result.top == 1
|
|
assert result.total is not None
|
|
|
|
|
|
# ------------------------------------------------- to_dict field narrowing
|
|
|
|
def test_to_dict_full_payload_is_unchanged_by_default(app, sample_app):
|
|
with app.app_context():
|
|
data = sample_app.to_dict()
|
|
for key in ('id', 'name', 'project_name', 'server_name', 'domains',
|
|
'image_scan', 'image_update', 'sleep', 'has_linked_app',
|
|
'ingress_plane'):
|
|
assert key in data
|
|
|
|
|
|
def test_to_dict_narrows_to_requested_fields(app, sample_app):
|
|
"""The point of $select: the expensive derived fields are not computed."""
|
|
with app.app_context():
|
|
data = sample_app.to_dict(fields={'id', 'name'})
|
|
assert set(data) == {'id', 'name'}
|
|
for skipped in ('image_scan', 'image_update', 'sleep', 'domains',
|
|
'project_name', 'server_name'):
|
|
assert skipped not in data
|
|
|
|
|
|
def test_to_dict_can_opt_into_one_derived_field(app, sample_app):
|
|
with app.app_context():
|
|
data = sample_app.to_dict(fields={'id', 'name', 'image_scan'})
|
|
assert set(data) == {'id', 'name', 'image_scan'}
|
|
|
|
|
|
# --------------------------------------------- Server: the same narrowing
|
|
|
|
def test_server_to_dict_is_unchanged_by_default(app):
|
|
from app import db
|
|
from app.models.server import Server
|
|
with app.app_context():
|
|
row = Server(id='probe-srv', name='probe')
|
|
db.session.add(row)
|
|
db.session.commit()
|
|
data = row.to_dict()
|
|
assert 'onboarding_progress' in data
|
|
assert 'group_name' in data
|
|
|
|
|
|
def test_server_to_dict_skips_the_expensive_derived_fields(app):
|
|
"""onboarding_progress alone is ~29% of a server row and is read by exactly
|
|
one screen; group_name is a relationship load."""
|
|
from app import db
|
|
from app.models.server import Server
|
|
with app.app_context():
|
|
row = Server(id='probe-srv2', name='probe2')
|
|
db.session.add(row)
|
|
db.session.commit()
|
|
data = row.to_dict(fields={'id', 'name'})
|
|
assert set(data) == {'id', 'name'}
|
|
assert 'onboarding_progress' not in data
|
|
assert 'group_name' not in data
|
|
|
|
|
|
def test_server_select_accepts_its_derived_fields(app):
|
|
from app.models.server import Server
|
|
with app.app_context():
|
|
fields = parse_select('name,group_name', Server, extra=Server.DERIVED_FIELDS)
|
|
assert fields == {'name', 'group_name', 'id'}
|
|
|
|
|
|
# --------------------------------------------------------------- PageQuery #
|
|
|
|
class TestPageQuery:
|
|
"""The page/per_page dialect parses, defaults, and bounds in ONE place."""
|
|
|
|
def _pq(self, args, **kw):
|
|
from app.api._query import PageQuery
|
|
return PageQuery.from_args(args, **kw)
|
|
|
|
def test_defaults(self):
|
|
pq = self._pq({})
|
|
assert (pq.page, pq.per_page, pq.skip) == (1, 50, 0)
|
|
|
|
def test_parses_and_computes_skip(self):
|
|
pq = self._pq({'page': '3', 'per_page': '20'})
|
|
assert (pq.page, pq.per_page, pq.skip) == (3, 20, 40)
|
|
|
|
def test_bounds_per_page_to_the_endpoint_cap(self):
|
|
assert self._pq({'per_page': '9999'}).per_page == 100
|
|
assert self._pq({'per_page': '9999'}, max_per_page=200).per_page == 200
|
|
|
|
def test_floors_page_zero_and_rejects_negatives(self):
|
|
from app.api._query import QueryParseError
|
|
assert self._pq({'page': '0'}).page == 1
|
|
with pytest.raises(QueryParseError):
|
|
self._pq({'per_page': '-5'})
|
|
|
|
def test_garbage_is_a_typed_400_not_a_silent_default(self):
|
|
from app.api._query import QueryParseError
|
|
with pytest.raises(QueryParseError):
|
|
self._pq({'page': 'banana'})
|
|
|
|
def test_queryparseerror_is_a_validation_error(self):
|
|
"""The global handler answers it as a 400 — no per-route translation."""
|
|
from app.api._query import QueryParseError
|
|
from app.exceptions import ValidationError
|
|
assert issubclass(QueryParseError, ValidationError)
|