diff --git a/backend/firestore.indexes.json b/backend/firestore.indexes.json index ed25c353..4c622e6b 100644 --- a/backend/firestore.indexes.json +++ b/backend/firestore.indexes.json @@ -12,6 +12,60 @@ } } ] + }, + { + "collectionGroup": "schemes", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "agency", + "order": "ASCENDING" + }, + { + "fieldPath": "last_scraped_update", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "schemes", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "planning_area", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "last_scraped_update", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "schemes", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "scheme_type", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "last_scraped_update", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [] diff --git a/backend/functions/main.py b/backend/functions/main.py index d8f2c8ef..f60139fc 100644 --- a/backend/functions/main.py +++ b/backend/functions/main.py @@ -53,6 +53,7 @@ from firebase_functions import https_fn, options from loguru import logger from new_scheme.trigger_new_scheme_pipeline import on_new_scheme_entry # noqa: F401 +from schemes.catalog import catalog # noqa: F401 from schemes.schemes import schemes # noqa: F401 from schemes.search import schemes_search # noqa: F401 from schemes.search_queries import retrieve_search_queries # noqa: F401 diff --git a/backend/functions/schemes/catalog.py b/backend/functions/schemes/catalog.py new file mode 100644 index 00000000..f528b7b7 --- /dev/null +++ b/backend/functions/schemes/catalog.py @@ -0,0 +1,249 @@ +""" +Handler for catalog endpoint + +URL for local testing: +http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog +http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?agency= +http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?area= +http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1/catalog?scheme_type= +""" + +import json +from dataclasses import asdict, dataclass +from typing import Callable + +from fb_manager.firebaseManager import FirebaseManager +from firebase_functions import https_fn, options +from google.cloud.firestore_v1 import FieldFilter +from loguru import logger +from utils.auth import verify_auth_token +from utils.catalog_pagination import PaginationResult, get_paginated_results +from utils.cors_config import get_cors_headers, handle_cors_preflight +from utils.json_utils import safe_json_dumps +from werkzeug.datastructures import MultiDict + + +DEFAULT_LIMIT = 10 + + +@dataclass(frozen=True) +class CatalogFilterSpec: + """Describes how a supported catalog query param maps to Firestore.""" + + firestore_field: str + operator: str + normalize: Callable[[str], str] + + +@dataclass(kw_only=True) +class CatalogRequestParams: + """Parsed catalog request parameters with an optional active filter.""" + + limit: int = DEFAULT_LIMIT + cursor: str | None = None + filter_name: str | None = None + filter_value: str | None = None + + +FILTER_SPECS = { + "agency": CatalogFilterSpec( + firestore_field="agency", + operator="==", + normalize=lambda value: value.title(), + ), + "area": CatalogFilterSpec( + firestore_field="planning_area", + operator="array_contains", + normalize=lambda value: value.upper(), + ), + "scheme_type": CatalogFilterSpec( + firestore_field="scheme_type", + operator="array_contains", + normalize=lambda value: value, + ), +} +ALLOWED_QUERY_PARAMS = set(FILTER_SPECS) | {"limit", "cursor", "is_warmup", "sort"} + + +def create_firebase_manager() -> FirebaseManager: + """Factory function to create a FirebaseManager instance.""" + + return FirebaseManager() + + +def _supported_catalog_query_message() -> str: + """Return the standard validation error for supported catalog query shapes.""" + + supported_queries = ["/catalog", *[f"/catalog?{name}=<{name}>" for name in FILTER_SPECS]] + return f"Error parsing query parameters; only {', '.join(supported_queries)} are supported" + + +@https_fn.on_request(region="asia-southeast1", memory=options.MemoryOption.GB_1) +def catalog(req: https_fn.Request) -> https_fn.Response: + """ + Handler for catalog endpoint + + Args: + req (https_fn.Request): request sent from client + + Returns: + https_fn.Response: response sent to client + """ + # Handle CORS preflight request + if req.method == "OPTIONS": + return handle_cors_preflight(req) + + # Get standard CORS headers for all other requests + headers = get_cors_headers(req) + + # Verify authentication + is_valid, auth_message = verify_auth_token(req) + if not is_valid: + return https_fn.Response( + response=json.dumps({"error": f"Authentication failed: {auth_message}"}), + status=401, + mimetype="application/json", + headers=headers, + ) + + firebase_manager = create_firebase_manager() + + if not req.method == "GET": + return https_fn.Response( + response=json.dumps({"error": "Invalid request method; only GET is supported"}), + status=405, + mimetype="application/json", + headers=headers, + ) + + # Check if this is a warmup request from the query parameters + is_warmup = req.args.get("is_warmup", "false").lower() == "true" + + if is_warmup: + return https_fn.Response( + response=json.dumps({"message": "Warmup request received"}), + status=200, + mimetype="application/json", + headers=headers, + ) + + try: + query_params = _parse_query_params(req.args) + except ValueError as e: + logger.exception("Error parsing query parameters", e) + return https_fn.Response( + response=json.dumps( + { + "error": _supported_catalog_query_message() + } + ), + status=400, + mimetype="application/json", + headers=headers, + ) + + try: + results = _handle_catalog_request(firebase_manager, query_params) + except Exception as e: + logger.exception("Unable to fetch scheme from firestore", e) + return https_fn.Response( + response=json.dumps({"error": "Internal server error, unable to fetch scheme from firestore"}), + status=500, + mimetype="application/json", + headers=headers, + ) + + if results.data is None or len(results.data) == 0: + return https_fn.Response( + response=json.dumps({"error": "No scheme found"}), + status=404, + mimetype="application/json", + headers=headers, + ) + + return https_fn.Response( + response=safe_json_dumps(asdict(results)), + status=200, + mimetype="application/json", + headers=headers, + ) + + +def _parse_query_params(query_params: MultiDict[str, str]) -> CatalogRequestParams: + """ + Parse request query parameters into CatalogRequestParams. + + Supported: + - /catalog + - /catalog?agency= + - /catalog?area= + - /catalog?scheme_type= + + Raises: + ValueError: If unsupported query parameters are provided. + """ + + # Validate unknown query parameters + unknown_params = set(query_params.keys()) - ALLOWED_QUERY_PARAMS + if unknown_params: + raise ValueError(f"Unsupported query parameter(s): {', '.join(sorted(unknown_params))}") + + selected_filters = [name for name in FILTER_SPECS if query_params.get(name)] + if len(selected_filters) > 1: + raise ValueError(f"Invalid query; {', '.join(repr(name) for name in selected_filters)} cannot be used together") + + # Retrieve limit and cursor from query parameters + limit = int(query_params.get("limit", DEFAULT_LIMIT)) + cursor = query_params.get("cursor", "") + + if not selected_filters: + return CatalogRequestParams(limit=limit, cursor=cursor) + + filter_name = selected_filters[0] + raw_value = query_params.get(filter_name, "") + if not raw_value.strip(): + raise ValueError(f"Invalid query; '{filter_name}' must be a non-empty value") + + spec = FILTER_SPECS[filter_name] + return CatalogRequestParams( + filter_name=filter_name, + filter_value=spec.normalize(raw_value.strip()), + limit=limit, + cursor=cursor, + ) + + +def _handle_catalog_request( + firebase_manager: FirebaseManager, + query_params: CatalogRequestParams, +) -> PaginationResult: + """Retrieve catalog entries with optional filter-based pagination. + + Args: + firebase_manager: Firebase manager providing Firestore access. + query_params: Parsed catalog parameters, including any active filter. + + Returns: + PaginationResult: + data: Documents for the current page. + next_cursor: Cursor for the next page, or None if exhausted. + has_more: Whether more results exist. + """ + col = firebase_manager.firestore_client.collection("schemes") + + if not query_params.filter_name or query_params.filter_value is None: + return get_paginated_results( + collection_ref=col, + cursor=query_params.cursor, + limit=query_params.limit, + ) + + spec = FILTER_SPECS[query_params.filter_name] + query = col.where(filter=FieldFilter(spec.firestore_field, spec.operator, query_params.filter_value)) + + return get_paginated_results( + collection_ref=col, + base_query=query, + cursor=query_params.cursor, + limit=query_params.limit, + ) diff --git a/backend/functions/utils/catalog_pagination.py b/backend/functions/utils/catalog_pagination.py new file mode 100644 index 00000000..b93a16fb --- /dev/null +++ b/backend/functions/utils/catalog_pagination.py @@ -0,0 +1,184 @@ +"""Helpers for cursor-based pagination in catalog Firestore queries.""" + +import base64 +import hashlib +import hmac +import json +import os +from dataclasses import dataclass +from typing import Any, Optional +from google.cloud.firestore_v1 import CollectionReference, Query +from loguru import logger + + +# Secret key for cursor signature +# In a production environment, this should be stored in environment variables +# or a secure configuration system +CURSOR_SECRET = os.environ.get("CURSOR_SECRET", "schemes_pagination_secret_key") + + +@dataclass +class PaginationResult: + """Page of serialized catalog results plus cursor metadata.""" + + data: list[dict[str, Any]] + next_cursor: Optional[str] = None + has_more: bool = False + + +def _encode_cursor(doc_id: str) -> str: + """Encode a signed pagination cursor for a Firestore document ID. + + Args: + doc_id: Firestore document ID for the last document on the page. + + Returns: + A URL-safe base64 cursor token containing the document ID and an + HMAC signature. + """ + # Create cursor data + cursor_data = {"doc_id": doc_id} + cursor_json = json.dumps(cursor_data) + + # Create signature for verification + signature = hmac.new(CURSOR_SECRET.encode(), cursor_json.encode(), hashlib.sha256).hexdigest() + + # Combine cursor data and signature + cursor_token = {"data": cursor_data, "signature": signature} + + # Encode as b64 + cursor_token_json = json.dumps(cursor_token).encode() + cursor_token_b64 = base64.urlsafe_b64encode(cursor_token_json).decode() + + return cursor_token_b64 + + +def _decode_cursor(cursor: str) -> Optional[str]: + """Decode and validate a pagination cursor. + + Args: + cursor: URL-safe base64 cursor token previously created by + `_encode_cursor`. + + Returns: + The Firestore document ID embedded in the cursor when the token is + present and the signature is valid. Returns `None` for malformed, + tampered, or otherwise invalid cursors. + """ + try: + # Decode cursor from b64 + cursor_token_json = base64.urlsafe_b64decode(cursor.encode()) + cursor_token = json.loads(cursor_token_json.decode()) + + # Extract data and signature + received_cursor_data = cursor_token.get("data") + received_signature = cursor_token.get("signature") + + if not received_cursor_data or not received_signature: + logger.warning("Invalid cursor format: missing data or signature") + return None + + # Verify signature + cursor_json = json.dumps(received_cursor_data) + expected_signature = hmac.new(CURSOR_SECRET.encode(), cursor_json.encode(), hashlib.sha256).hexdigest() + + if not hmac.compare_digest(received_signature, expected_signature): + logger.warning("Cursor signature verification failed") + return None + + return received_cursor_data.get("doc_id") + except Exception as e: + logger.warning(f"Error decoding cursor: {e}") + return None + + +def _get_paginated_query( + collection_ref: CollectionReference, + base_query: Optional[Query] = None, + cursor: Optional[str] = None, + limit: int = 10, +) -> Query: + """Build a Firestore query with ordering, limit, and optional cursor. + + The query always orders by `last_scraped_update` from newest to oldest and + uses `__name__` as an ascending tie-breaker. It requests `limit + 1` + documents so the caller can determine whether another page exists. When a + cursor is provided, the corresponding document snapshot is fetched and used + with `start_at(...)`. + + Args: + collection_ref: Base Firestore collection for the catalog. + base_query: Optional filtered query to paginate instead of the full + collection. + cursor: Optional signed cursor token from a previous page. + limit: Number of documents requested by the caller before the extra + lookahead document is applied. + + Returns: + A Firestore query ready to execute. + """ + # Order by newest updates first and add __name__ as a deterministic + # ascending secondary sort key for documents sharing the same timestamp. + q = ( + base_query.order_by("last_scraped_update", direction=Query.DESCENDING) + .limit(limit + 1) + if base_query + else collection_ref.order_by("last_scraped_update", direction=Query.DESCENDING) + .limit(limit + 1) + ) + + if not cursor: + return q + + # Decode cursor to get doc_id to start_at + doc_id = _decode_cursor(cursor) + if not doc_id: + return q + + try: + snapshot = collection_ref.document(doc_id).get() + if getattr(snapshot, "exists", False): + return q.start_at(snapshot) + logger.warning(f"Cursor doc_id not found in collection: {doc_id}") + return q + except Exception as e: + logger.error(f"Error fetching snapshot for cursor doc_id {doc_id}: {e}") + return q + + +def get_paginated_results( + collection_ref: CollectionReference, + base_query: Optional[Query] = None, + cursor: Optional[str] = None, + limit: int = 10, +) -> PaginationResult: + """Execute a paginated catalog query and serialize the result page. + + Args: + collection_ref: Firestore collection reference. + base_query: Optional filtered query to paginate. + cursor: Optional signed cursor token from a previous response. + limit: Maximum number of results to return in this page. + + The function fetches `limit + 1` documents internally to determine whether + another page exists. Returned documents are converted to dictionaries with + `DocumentSnapshot.to_dict()`. + + Returns: + Pagination metadata and serialized document data for the current page. + """ + # Set default return values + docs = [] + has_more = False + next_cursor = None + + ref = _get_paginated_query(collection_ref, base_query, cursor, limit) + + docs = ref.get() + + has_more = len(docs) > limit + if has_more: + next_cursor = _encode_cursor(docs[-1].id) + docs = docs[:-1] + + return PaginationResult(data=[doc.to_dict() for doc in docs], next_cursor=next_cursor, has_more=has_more) diff --git a/backend/functions/utils/endpoints.py b/backend/functions/utils/endpoints.py index 5257d7c7..22318ded 100644 --- a/backend/functions/utils/endpoints.py +++ b/backend/functions/utils/endpoints.py @@ -13,6 +13,7 @@ - feedback: POST endpoint for user feedback - update_scheme: POST endpoint for scheme update requests - search_queries: GET endpoint for retrieving search history +- catalog: GET endpoint for retrieving scheme catalogs All endpoints support an `is_warmup` parameter that, when true, will return a 200 status immediately without performing any database operations. This helps reduce unnecessary @@ -176,6 +177,12 @@ def keep_endpoints_warm(event: scheduler_fn.ScheduledEvent) -> None: "url": f"{get_endpoint_url('schemes')}/1?is_warmup=true", # Endpoint will return 200 immediately "data": None, }, + { + "name": "catalog", + "method": "GET", + "url": f"{get_endpoint_url('catalog')}?is_warmup=true", # Endpoint will return 200 immediately + "data": None, + }, { "name": "chat_message", "method": "POST", diff --git a/backend/test-endpoint.http b/backend/test-endpoint.http new file mode 100644 index 00000000..93345ccc --- /dev/null +++ b/backend/test-endpoint.http @@ -0,0 +1,65 @@ +@base=http://127.0.0.1:5001/schemessg-v3-dev/asia-southeast1 +@token= + +### Healthcheck +GET {{base}}/health +Authorization: Bearer {{token}} + +### Retrieve all schemes +GET {{base}}/catalog +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes with limit +GET {{base}}/catalog?limit=2 +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes with limit and cursor +GET {{base}}/catalog?limit=2&cursor= +Content-Type: application/json + +### Retrieve all schemes sorted by agency +GET {{base}}/catalog?agency=fei+yue +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve schemes by agency (e.g., Fei Yue) with limit +GET {{base}}/catalog?agency=fei+yue&limit=1 +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve schemes by agency (e.g., Fei Yue) with limit and cursor +GET {{base}}/catalog?agency=fei+yue&limit=1&cursor= +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes sorted by planning area (e.g., choa chu kang) +GET {{base}}/catalog?area=choa+chu+kang +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes by planning with limit +GET {{base}}/catalog?area=choa+chu+kang&limit=2 +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes by planning with limit and cursor +GET {{base}}/catalog?area=choa+chu+kang&limit=1&cursor= +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes by scheme type +GET {{base}}/catalog?scheme_type=healthcare +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes by scheme type with limit +GET {{base}}/catalog?scheme_type=healthcare&limit=2 +Authorization: Bearer {{token}} +Content-Type: application/json + +### Retrieve all schemes by scheme type with limit and cursor +GET {{base}}/catalog?scheme_type=healthcare&limit=1&cursor= +Authorization: Bearer {{token}} +Content-Type: application/json diff --git a/backend/tests/integration/test_catalog.py b/backend/tests/integration/test_catalog.py new file mode 100644 index 00000000..c9c59dfa --- /dev/null +++ b/backend/tests/integration/test_catalog.py @@ -0,0 +1,178 @@ +"""Tests for the catalog endpoint.""" + +import json + +from schemes.catalog import CatalogRequestParams, _handle_catalog_request, _parse_query_params, catalog +from utils.catalog_pagination import PaginationResult +from werkzeug.datastructures import MultiDict + + +def test_catalog_warmup_request(mock_request, mock_https_response, mock_auth, mocker): + """Test catalog endpoint with warmup request.""" + mock_manager = mocker.MagicMock() + mocker.patch("schemes.catalog.create_firebase_manager", return_value=mock_manager) + + request = mock_request(method="GET", args={"is_warmup": "true"}) + + response = catalog(request) + + assert response.status_code == 200 + response_data = json.loads(response.get_data()) + assert "Warmup request received" == response_data["message"] + + +def test_catalog_invalid_method(mock_request, mock_https_response, mock_auth, mocker): + """Test catalog endpoint with invalid HTTP method.""" + mock_manager = mocker.MagicMock() + mocker.patch("schemes.catalog.create_firebase_manager", return_value=mock_manager) + + request = mock_request(method="POST") + + response = catalog(request) + + assert response.status_code == 405 + response_data = json.loads(response.get_data()) + assert "Invalid request method; only GET is supported" == response_data["error"] + + +def test_catalog_invalid_query(mock_request, mock_https_response, mock_auth, mocker): + """Test catalog endpoint with invalid query parameters.""" + mock_manager = mocker.MagicMock() + mocker.patch("schemes.catalog.create_firebase_manager", return_value=mock_manager) + + request = mock_request(method="GET", args={"area": "TAMPINES", "scheme_type": "healthcare"}) + + response = catalog(request) + + assert response.status_code == 400 + response_data = json.loads(response.get_data()) + assert "Error parsing query parameters" in response_data["error"] + + +def test_catalog_successful_scheme_type_fetch(mock_request, mock_https_response, mock_auth, mocker): + """Test successful catalog fetch with scheme_type filtering.""" + mock_collection = mocker.MagicMock() + mock_query = mocker.MagicMock() + mock_collection.where.return_value = mock_query + + mock_manager = mocker.MagicMock() + mock_manager.firestore_client.collection.return_value = mock_collection + + mocker.patch("schemes.catalog.create_firebase_manager", return_value=mock_manager) + field_filter = mocker.patch("schemes.catalog.FieldFilter", return_value="scheme-type-filter") + mocker.patch( + "schemes.catalog.get_paginated_results", + return_value=PaginationResult( + data=[{"scheme_name": "Test Scheme", "scheme_type": ["healthcare"]}], + next_cursor="next-page", + has_more=True, + ), + ) + + request = mock_request(method="GET", args={"scheme_type": "healthcare", "limit": "2"}) + + response = catalog(request) + + assert response.status_code == 200 + response_data = json.loads(response.get_data()) + assert response_data["data"][0]["scheme_name"] == "Test Scheme" + assert response_data["next_cursor"] == "next-page" + assert response_data["has_more"] is True + mock_manager.firestore_client.collection.assert_called_once_with("schemes") + field_filter.assert_called_once_with("scheme_type", "array_contains", "healthcare") + mock_collection.where.assert_called_once_with(filter="scheme-type-filter") + + +def test_catalog_not_found(mock_request, mock_https_response, mock_auth, mocker): + """Test catalog endpoint when no schemes are found.""" + mock_manager = mocker.MagicMock() + mocker.patch("schemes.catalog.create_firebase_manager", return_value=mock_manager) + mocker.patch("schemes.catalog.get_paginated_results", return_value=PaginationResult(data=[])) + + request = mock_request(method="GET", args={"scheme_type": "healthcare"}) + + response = catalog(request) + + assert response.status_code == 404 + response_data = json.loads(response.get_data()) + assert "No scheme found" == response_data["error"] + + +def test_catalog_firestore_error(mock_request, mock_https_response, mock_auth, mocker): + """Test catalog endpoint when Firestore query fails.""" + mock_collection = mocker.MagicMock() + mock_collection.where.side_effect = Exception("Firestore error") + + mock_manager = mocker.MagicMock() + mock_manager.firestore_client.collection.return_value = mock_collection + + mocker.patch("schemes.catalog.create_firebase_manager", return_value=mock_manager) + mocker.patch("schemes.catalog.FieldFilter", return_value="scheme-type-filter") + + request = mock_request(method="GET", args={"scheme_type": "healthcare"}) + + response = catalog(request) + + assert response.status_code == 500 + response_data = json.loads(response.get_data()) + assert "Internal server error" in response_data["error"] + + +def test_catalog_cors_preflight(mock_request, mock_https_response, mock_auth, mocker): + """Test catalog endpoint CORS preflight request.""" + request = mock_request(method="OPTIONS") + + response = catalog(request) + + assert response.status_code == 204 + assert response.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000" + + +def test_parse_query_params_scheme_type(): + """Parse a scheme_type catalog request.""" + params = _parse_query_params(MultiDict([("scheme_type", "healthcare"), ("limit", "5"), ("cursor", "abc")])) + + assert isinstance(params, CatalogRequestParams) + assert params.filter_name == "scheme_type" + assert params.filter_value == "healthcare" + assert params.limit == 5 + assert params.cursor == "abc" + + +def test_parse_query_params_rejects_multiple_filters(): + """Reject requests that mix catalog filter types.""" + try: + _parse_query_params(MultiDict([("area", "TAMPINES"), ("scheme_type", "healthcare")])) + assert False, "Expected ValueError for multiple filters" + except ValueError as exc: + assert "'area', 'scheme_type'" in str(exc) + + +def test_handle_catalog_request_uses_array_contains_for_scheme_type(mocker, mock_firebase_manager): + """Build a Firestore array_contains query for scheme_type.""" + mock_collection = mocker.MagicMock() + mock_query = mocker.MagicMock() + mock_collection.where.return_value = mock_query + mock_firebase_manager.firestore_client.collection.return_value = mock_collection + + field_filter = mocker.patch("schemes.catalog.FieldFilter", return_value="scheme-type-filter") + get_paginated_results = mocker.patch("schemes.catalog.get_paginated_results") + + query_params = CatalogRequestParams( + filter_name="scheme_type", + filter_value="healthcare", + limit=3, + cursor="next-page", + ) + + _handle_catalog_request(mock_firebase_manager, query_params) + + mock_firebase_manager.firestore_client.collection.assert_called_once_with("schemes") + field_filter.assert_called_once_with("scheme_type", "array_contains", "healthcare") + mock_collection.where.assert_called_once_with(filter="scheme-type-filter") + get_paginated_results.assert_called_once_with( + collection_ref=mock_collection, + base_query=mock_query, + cursor="next-page", + limit=3, + )