diff --git a/.github/workflows/test_end_to_end.yml b/.github/workflows/test_end_to_end.yml new file mode 100644 index 00000000..2c0b41dd --- /dev/null +++ b/.github/workflows/test_end_to_end.yml @@ -0,0 +1,102 @@ +name: End to End Data Pipeline Test + +on: + push: + branches: + - main + - add_end_to_end_tests + + workflow_dispatch: + inputs: + debug_enabled: + description: "Run the build with tmate debugging enabled" + required: false + default: false +jobs: + build-and-run: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: cache inputs and outputs + uses: actions/cache@v5 + with: + key: data-cache-${{ github.run_id }} + restore-keys: | + data-cache- + path: | + tests/integration/data + tests/integration/fastpath + + - name: change permission of fastpath cache for container read/write + run: | + chmod 0777 -R tests/integration/fastpath/lib/cache + + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled }} + with: + limit-access-to-actor: true + + - name: Run Docker Compose (detached) + run: | + cd tests/integration + docker compose up -d + docker compose ps --all + docker compose logs --no-color --timestamps --tail=200 & + + - name: Wait for verifier to start + run: | + cd tests/integration + for i in $(seq 1 60); do + if docker ps --format "{{.Names}}" | grep -q 'integration-verify'; then + echo "verifier started" + exit 0 + fi + sleep 10 + done + echo "verifier did not start within timeout" >&2 + docker ps -a + exit 1 + + - name: Stream verifier logs until it exits + run: | + cd tests/integration + # Start streaming logs; docker logs --follow will exit when the container stops. + docker logs --follow integration-verify-1 & LOG_PID=$! + # Wait for verifier container to stop, with a timeout (360*5s = 30 minutes). + for i in $(seq 1 360); do + if ! docker ps --format "{{.Names}}" | grep -q 'integration-verify'; then + echo "verifier exited" + break + fi + sleep 5 + done + # If still running after timeout, show debugging info and fail. + if docker ps --format "{{.Names}}" | grep -q 'integration-verify'; then + echo "verifier did not finish within timeout" >&2 + docker ps -a + docker logs integration-verify-1 || true + if ps -p $LOG_PID > /dev/null; then + kill $LOG_PID || true + fi + exit 1 + fi + # Wait for the logs follower to finish and show final logs if any. + if ps -p $LOG_PID > /dev/null; then + wait $LOG_PID || true + fi + docker ps -a --filter name=verify --format "Container: {{.Names}} Status: {{.Status}}" + docker logs integration-verify-1 || true + EXIT_CODE=$(docker inspect --format='{{.State.ExitCode}}' integration-verify-1) + if [ "$EXIT_CODE" -ne 0 ]; then + echo "Container exited with code $EXIT_CODE" + exit $EXIT_CODE + fi + + - name: Shutdown remaining services + run: | + cd tests/integration + docker compose down --remove-orphans diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6f0f1cae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Use a specific Python image version (if compatible) +FROM python:3.11 + +# Set the working directory +WORKDIR /app + +# Copy oonidata and oonipipeline source files into the container +COPY oonidata ./oonidata +COPY oonipipeline ./oonipipeline + +# Install dependencies for both projects +RUN pip install ./oonidata && pip install ./oonipipeline + +# Set the default command for the container +CMD ["/bin/bash"] diff --git a/oonipipeline/pyproject.toml b/oonipipeline/pyproject.toml index 1c8f92aa..a619df00 100644 --- a/oonipipeline/pyproject.toml +++ b/oonipipeline/pyproject.toml @@ -69,6 +69,9 @@ path = ".venv/" [tool.hatch.version] path = "src/oonipipeline/__about__.py" +[project.scripts] +oonipipeline = "oonipipeline.main:cli" + [tool.hatch.envs.default.scripts] oonipipeline = "python -m oonipipeline.main {args}" dataviz = "uvicorn oonipipeline.dataviz.main:app {args}" diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile new file mode 100644 index 00000000..e5bc6d16 --- /dev/null +++ b/tests/integration/Dockerfile @@ -0,0 +1,14 @@ +# Use a specific Python image version (if compatible) +FROM python:3.11 + +# Set the working directory +WORKDIR /app + +# Copy oonidata and oonipipeline source files into the container +COPY ./tests ./app + +# Install dependencies for both projects +RUN pip install pytest httpx jwt + +# Set the default command for the container +CMD ["/bin/bash"] diff --git a/tests/integration/clickhouse_init.sql b/tests/integration/clickhouse_init.sql new file mode 100644 index 00000000..36a30d1f --- /dev/null +++ b/tests/integration/clickhouse_init.sql @@ -0,0 +1,82 @@ +CREATE TABLE ooni.fingerprints_dns +( + `name` String, + `scope` Enum8('nat' = 1, 'isp' = 2, 'prod' = 3, 'inst' = 4, 'vbw' = 5, 'fp' = 6), + `other_names` String, + `location_found` String, + `pattern_type` Enum8('full' = 1, 'prefix' = 2, 'contains' = 3, 'regexp' = 4), + `pattern` String, + `confidence_no_fp` UInt8, + `expected_countries` String, + `source` String, + `exp_url` String, + `notes` String +) +ENGINE = EmbeddedRocksDB +PRIMARY KEY name; + +CREATE TABLE ooni.fingerprints_http +( + `name` String, + `scope` Enum8('nat' = 1, 'isp' = 2, 'prod' = 3, 'inst' = 4, 'vbw' = 5, 'fp' = 6, 'injb' = 7, 'prov' = 8), + `other_names` String, + `location_found` String, + `pattern_type` Enum8('full' = 1, 'prefix' = 2, 'contains' = 3, 'regexp' = 4), + `pattern` String, + `confidence_no_fp` UInt8, + `expected_countries` String, + `source` String, + `exp_url` String, + `notes` String +) +ENGINE = EmbeddedRocksDB +PRIMARY KEY name; + +CREATE TABLE ooni.fastpath +( + `measurement_uid` String, + `report_id` String, + `input` String, + `probe_cc` LowCardinality(String), + `probe_asn` Int32, + `test_name` LowCardinality(String), + `test_start_time` DateTime, + `measurement_start_time` DateTime, + `filename` String, + `scores` String, + `platform` String, + `anomaly` String, + `confirmed` String, + `msm_failure` String, + `domain` String, + `software_name` String, + `software_version` String, + `control_failure` String, + `blocking_general` Float32, + `is_ssl_expected` Int8, + `page_len` Int32, + `page_len_ratio` Float32, + `server_cc` String, + `server_asn` Int8, + `server_as_name` String, + `update_time` DateTime64(3) MATERIALIZED now64(), + `test_version` String, + `architecture` String, + `engine_name` LowCardinality(String), + `engine_version` String, + `test_runtime` Float32, + `blocking_type` String, + `test_helper_address` LowCardinality(String), + `test_helper_type` LowCardinality(String), + `ooni_run_link_id` Nullable(UInt64), + `is_verified` Int8 DEFAULT 0, + `nym` String DEFAULT '', + `zkp_request` String DEFAULT '', + `age_range` String DEFAULT '', + `msm_range` String DEFAULT '', + INDEX fastpath_rid_idx report_id TYPE minmax GRANULARITY 1, + INDEX measurement_uid_idx measurement_uid TYPE minmax GRANULARITY 8 +) +ENGINE = ReplacingMergeTree(update_time) +ORDER BY (measurement_start_time, report_id, input, measurement_uid) +SETTINGS index_granularity = 8192; diff --git a/tests/integration/data/.gitignore b/tests/integration/data/.gitignore new file mode 100644 index 00000000..78d91016 --- /dev/null +++ b/tests/integration/data/.gitignore @@ -0,0 +1,2 @@ +/* +!.gitignore diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 00000000..54fd6595 --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,121 @@ +version: '3.8' + +services: + downloader: + build: + context: ../../ + dockerfile: Dockerfile + image: oonidata_image + volumes: + - ./data:/data:Z + working_dir: /data + command: > + bash -c "oonidata sync --output-dir . --probe-cc IT,IR --start-day 2026-01-01 --end-day 2026-01-10 && + oonipipeline run --create-tables --probe-cc IT,IR --workflow-name observations --start-at 2026-01-01 --end-at 2026-01-10" + depends_on: + clickhouse: + condition: service_healthy + environment: + CLICKHOUSE_URL: "http://testuser:testuser@clickhouse:9000/ooni" + + clickhouse: + image: docker.io/clickhouse/clickhouse-server:latest + ports: + - "8123:8123" # HTTP interface + - "9000:9000" # Native interface + volumes: + - ./clickhouse_init.sql:/docker-entrypoint-initdb.d/init.sql:Z + environment: + CLICKHOUSE_USER: "testuser" + CLICKHOUSE_PASSWORD: "testuser" + CLICKHOUSE_DB: "ooni" + healthcheck: + test: ["CMD", "clickhouse-client", "-u", "testuser", "--password", "testuser", "-q", "SELECT 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + postgres: + image: docker.io/library/postgres:latest + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ooni + + fastpath: + image: docker.io/ooni/fastpath:latest + volumes: + - ./fastpath/etc:/etc/ooni:Z + - ./fastpath/lib:/var/lib/fastpath:Z + command: [ "./run_fastpath", "--debug", "--clickhouse-url", + "clickhouse://testuser:testuser@clickhouse:9000/ooni", + "--keep-s3-cache", "--write-to-disk", "--stdout", "--ccs", "IT,IR", + "--start-day", "2026-01-01", "--end-day", "2026-01-02", "--noapi" ] + depends_on: + clickhouse: + condition: service_healthy + postgres: + condition: service_started + + valkey: + image: docker.io/valkey/valkey:latest + ports: + - "6379:6379" + #volumes: + # - valkey_data:/data + command: valkey-server --appendonly yes + restart: unless-stopped + + api: + image: docker.io/ooni/api-oonimeasurements:latest + environment: + VALKEY_URL: valkey://valkey:6379 + POSTGRESQL_URL: "postgresql://postgres:postgres@postgres:5432/ooni" + JWT_ENCRYPTION_KEY: "0123456789abcdef" + PROMETHEUS_METRICS_PASSWORD: "testme" + CLICKHOUSE_URL: "http://testuser:testuser@clickhouse:9000/ooni" + ACCOUNT_ID_HASHING_KEY: "0123456789abcdef" + RATE_LIMITS: "1000/minute;400000/day;200000/7day" + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + ports: + - "8080:80" + + depends_on: + clickhouse: + condition: service_healthy + postgres: + condition: service_started + valkey: + condition: service_started + downloader: + condition: service_completed_successfully + fastpath: + condition: service_completed_successfully + + # replace this with a pytest suite that verifies results + verify: + build: + context: . + dockerfile: Dockerfile + image: verifier + + working_dir: /app + volumes: + - ./tests:/app:Z + environment: + API_URL: http://api:80 + command: > + pytest -v + depends_on: + api: + condition: service_healthy diff --git a/tests/integration/fastpath/etc/fastpath.conf b/tests/integration/fastpath/etc/fastpath.conf new file mode 100644 index 00000000..86b25d76 --- /dev/null +++ b/tests/integration/fastpath/etc/fastpath.conf @@ -0,0 +1,11 @@ +[DEFAULT] +# Collector hostnames, comma separated +collectors = localhost +clickhouse_url = clickhouse://testuser:testuser@clickhouse:9000/ooni +# S3 access credentials +# Currently unused +s3_access_key = +s3_secret_key = + +debug = true +msmt_spool_dir = /data/ diff --git a/tests/integration/fastpath/lib/cache/.gitignore b/tests/integration/fastpath/lib/cache/.gitignore new file mode 100644 index 00000000..78d91016 --- /dev/null +++ b/tests/integration/fastpath/lib/cache/.gitignore @@ -0,0 +1,2 @@ +/* +!.gitignore diff --git a/tests/integration/tests/conftest.py b/tests/integration/tests/conftest.py new file mode 100644 index 00000000..8ca007eb --- /dev/null +++ b/tests/integration/tests/conftest.py @@ -0,0 +1,107 @@ +import time +import jwt +import pytest +import httpx +from typing import Optional, Any +import os + + +class APIClient: + def __init__(self, base_url: str, timeout: float = 10.0, **kwargs: Any): + """ + Initialize the API client with a base URL. + + Args: + base_url: The base URL for all requests (e.g., "http://localhost:8000") + timeout: Request timeout in seconds (default: 10.0) + **kwargs: Additional arguments passed to httpx.Client + """ + self.base_url = base_url.rstrip("/") + self.client = httpx.Client(base_url=self.base_url, timeout=timeout, **kwargs) + + def get(self, path: str, **kwargs: Any) -> httpx.Response: + """GET request""" + return self.client.get(path, **kwargs) + + def post(self, path: str, **kwargs: Any) -> httpx.Response: + """POST request""" + return self.client.post(path, **kwargs) + + def put(self, path: str, **kwargs: Any) -> httpx.Response: + """PUT request""" + return self.client.put(path, **kwargs) + + def patch(self, path: str, **kwargs: Any) -> httpx.Response: + """PATCH request""" + return self.client.patch(path, **kwargs) + + def delete(self, path: str, **kwargs: Any) -> httpx.Response: + """DELETE request""" + return self.client.delete(path, **kwargs) + + def head(self, path: str, **kwargs: Any) -> httpx.Response: + """HEAD request""" + return self.client.head(path, **kwargs) + + def close(self) -> None: + """Close the client connection""" + self.client.close() + + def __enter__(self): + """Context manager entry""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit""" + self.close() + +@pytest.fixture +def client(): + yield APIClient(os.environ.get("API_URL")) + +def create_jwt(payload: dict) -> str: + return jwt.encode(payload, "super_secure", algorithm="HS256") + + +def create_session_token(account_id: str, role: str) -> str: + now = int(time.time()) + payload = { + "nbf": now, + "iat": now, + "exp": now + 10 * 86400, + "aud": "user_auth", + "account_id": account_id, + "login_time": None, + "role": role, + } + return create_jwt(payload) + + +@pytest.fixture +def client_with_user_role(client): + jwt_token = create_session_token("0" * 16, "user") + client.headers = {"Authorization": f"Bearer {jwt_token}"} + yield client + + +@pytest.fixture +def client_with_admin_role(client): + jwt_token = create_session_token("0" * 16, "admin") + client.headers = {"Authorization": f"Bearer {jwt_token}"} + yield client + + +@pytest.fixture +def params_since_and_until_with_two_days(): + return set_since_and_until_params(since="2026-01-01", until="2026-01-02") + + +@pytest.fixture +def params_since_and_until_with_ten_days(): + return set_since_and_until_params(since="2026-01-01", until="2026-01-10") + + +def set_since_and_until_params(since, until): + params = {"since": since, "until": until} + + return params diff --git a/tests/integration/tests/test_oonidata_aggregate_analysis.py b/tests/integration/tests/test_oonidata_aggregate_analysis.py new file mode 100644 index 00000000..95941741 --- /dev/null +++ b/tests/integration/tests/test_oonidata_aggregate_analysis.py @@ -0,0 +1,145 @@ +import pytest +import os + +route = "/api/v1/aggregation/analysis" +since = "2026-01-01" +until = "2026-01-10" + + +def test_oonidata_aggregation_analysis(client): + response = client.get(route) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 0 + + +def test_oonidata_aggregation_analysis_with_since_and_until( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + + for result in json["results"]: + assert "domain" in result, result + + +@pytest.mark.parametrize( + "filter_param, filter_value", + [ + ("domain", "zh.wikipedia.org"), + ("probe_cc", "IR"), + ("probe_asn", 1267), + ("test_name", "whatsapp"), + ("input", "stun://stun.voys.nl:3478"), + ], +) +def test_oonidata_aggregation_analysis_with_filters( + client, filter_param, filter_value, params_since_and_until_with_ten_days +): + params = params_since_and_until_with_ten_days + params[filter_param] = filter_value + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result[filter_param] == filter_value, result + + +def test_oonidata_aggregation_analysis_filtering_by_probe_asn_as_a_string_with_since_and_until( + client, params_since_and_until_with_ten_days +): + params = params_since_and_until_with_ten_days + probe_asn = 12874 + params["probe_asn"] = "AS" + str(probe_asn) + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result["probe_asn"] == probe_asn, result + + +@pytest.mark.parametrize( + "field", + [ + "measurement_start_day", + "domain", + "probe_cc", + "probe_asn", + "test_name", + "input", + ], +) +def test_oonidata_aggregation_analysis_with_axis_x( + client, field, params_since_and_until_with_ten_days +): + params = params_since_and_until_with_ten_days + params["axis_x"] = field + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result[field] is not None, result + + +@pytest.mark.parametrize( + "field", + [ + "measurement_start_day", + "domain", + "probe_cc", + "probe_asn", + "test_name", + "input", + ], +) +def test_oonidata_aggregation_analysis_axis_y( + client, field, params_since_and_until_with_ten_days +): + params = params_since_and_until_with_ten_days + params["axis_y"] = field + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result[field] is not None, result + + +@pytest.mark.parametrize( + "time_grain, total", + [ + ("hour", 216), + ("day", 9), + ("week", 2), + ("month", 1), + ("year", 1), + ("auto", 9), + ], +) +def test_oonidata_aggregation_analysis_time_grain( + client, time_grain, total, params_since_and_until_with_ten_days +): + params = params_since_and_until_with_ten_days + params["group_by"] = "timestamp" + params["time_grain"] = time_grain + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == total diff --git a/tests/integration/tests/test_oonidata_aggregate_observations.py b/tests/integration/tests/test_oonidata_aggregate_observations.py new file mode 100644 index 00000000..c9695561 --- /dev/null +++ b/tests/integration/tests/test_oonidata_aggregate_observations.py @@ -0,0 +1,94 @@ +import pytest + +route = "/api/v1/aggregation/observations" + + +def test_oonidata_aggregation_observations(client): + response = client.get(route) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 0 + + +def test_oonidata_aggregation_observations_with_since_and_until( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + + for result in json["results"]: + assert "observation_count" in result, result + assert "failure" in result, result + + +@pytest.mark.parametrize( + "filter_name, filter_value", + [ + ("probe_cc", "IT"), + ("probe_asn", 1267), + ("probe_asn", [1267, 5650]), + ("test_name", "whatsapp"), + ("hostname", "e7.whatsapp.net"), + ("ip", "15.197.206.217"), + ], +) +def test_oonidata_aggregation_observations_with_filters( + client, filter_name, filter_value, params_since_and_until_with_ten_days +): + params = params_since_and_until_with_ten_days + params[filter_name] = filter_value + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + if isinstance(filter_value, list): + assert result[filter_name] in filter_value, result + else: + assert result[filter_name] == filter_value, result + + +@pytest.mark.parametrize( + "time_grain, total", + [ + ("hour", 216), + ("day", 9), + ("week", 2), + ("month", 1), + ("year", 1), + ("auto", 9), + ], +) +def test_oonidata_aggregation_observations_time_grain( + client, time_grain, total, params_since_and_until_with_ten_days +): + params = params_since_and_until_with_ten_days + params["group_by"] = "timestamp" + params["time_grain"] = time_grain + + response = client.get(route, params=params) + + json = response.json() + assert len(json["results"]) == total + + +def test_oonidata_aggregation_observations_groupby_failure( + client, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params["group_by"] = ["failure", "timestamp"] + + response = client.get(route, params=params) + + json = response.json() + assert len(json["results"]) > 10 + first_result = json["results"][0] + assert "failure" in first_result.keys() + assert "timestamp" in first_result.keys() + assert "observation_count" in first_result.keys() diff --git a/tests/integration/tests/test_oonidata_list_analysis.py b/tests/integration/tests/test_oonidata_list_analysis.py new file mode 100644 index 00000000..de6b8777 --- /dev/null +++ b/tests/integration/tests/test_oonidata_list_analysis.py @@ -0,0 +1,165 @@ +import pytest + +route = "/api/v1/analysis" +since = "2026-01-01" +until = "2026-01-10" + + +def test_oonidata_list_analysis(client): + response = client.get(route) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 0 + + +def test_oonidata_list_analysis_with_since_and_until( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert "test_name" in result, result + assert "probe_cc" in result, result + + +@pytest.mark.parametrize( + "filter_param, filter_value", + [ + ( + "measurement_uid", + "20260102000000.504120_IT_webconnectivity_9de2bdd30c705f7a", + ), + ("probe_asn", 1267), + ("probe_cc", "IT"), + ("test_name", "web_connectivity"), + ], +) +def test_oonidata_list_analysis_with_filters( + client, filter_param, filter_value, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params[filter_param] = filter_value + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result[filter_param] == filter_value, result + + +def test_oonidata_list_analysis_filtering_by_probe_asn_as_a_string_with_since_and_until( + client, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + probe_asn = 1267 + params["probe_asn"] = "AS" + str(probe_asn) + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result["probe_asn"] == probe_asn, result + + +def test_oonidata_list_analysis_order_default( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for i in range(1, len(json["results"])): + assert "measurement_start_time" in json["results"][i], json["results"][i] + previous_date = json["results"][i - 1]["measurement_start_time"] + current_date = json["results"][i]["measurement_start_time"] + assert ( + previous_date >= current_date + ), f"The dates are not ordered: {previous_date} < {current_date}" + + +def test_oonidata_list_analysis_order_asc(client, params_since_and_until_with_two_days): + params = params_since_and_until_with_two_days + params["order"] = "ASC" + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for i in range(1, len(json["results"])): + assert "measurement_start_time" in json["results"][i], json["results"][i] + previous_date = json["results"][i - 1]["measurement_start_time"] + current_date = json["results"][i]["measurement_start_time"] + assert ( + previous_date <= current_date + ), f"The dates are not ordered: {previous_date} > {current_date}" + + +@pytest.mark.parametrize( + "field, order", + [ + ("input", "asc"), + ("probe_cc", "asc"), + ("probe_asn", "asc"), + ("test_name", "asc"), + ("input", "desc"), + ("probe_cc", "desc"), + ("probe_asn", "desc"), + ("test_name", "desc"), + ], +) +def test_oonidata_list_analysis_order_by_field( + client, field, order, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params["order_by"] = field + params["order"] = order + + response = client.get(route, params=params) + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for i in range(1, len(json["results"])): + assert field in json["results"][i], json["results"][i] + previous = json["results"][i - 1][field] + current = json["results"][i][field] + if order == "asc": + assert ( + previous <= current + ), f"The {field} values are not ordered in ascending order: {previous} > {current}" + else: + assert ( + previous >= current + ), f"The {field} values are not ordered in descending order: {previous} < {current}" + + +def test_oonidata_list_analysis_limit_by_default( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 100 + + +def test_oonidata_list_analysis_with_limit_and_offset( + client, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params["limit"] = 10 + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 10 diff --git a/tests/integration/tests/test_oonidata_list_observations.py b/tests/integration/tests/test_oonidata_list_observations.py new file mode 100644 index 00000000..7d2d520e --- /dev/null +++ b/tests/integration/tests/test_oonidata_list_observations.py @@ -0,0 +1,167 @@ +import pytest + +route = "/api/v1/observations" + + +def test_oonidata_list_observations(client): + response = client.get(route) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 0 + + +def test_oonidata_list_observations_with_since_and_until( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert "test_name" in result, result + assert "probe_cc" in result, result + + +@pytest.mark.parametrize( + "filter_name, filter_value", + [ + ("report_id", "20260101T235954Z_webconnectivity_IT_1267_n4_ozfKmtNGTeLyiVbE"), + ("probe_asn", 1267), + ("probe_cc", "IT"), + ("software_name", "ooniprobe-android-unattended"), + ("software_version", "5.3.0"), + ("test_name", "web_connectivity"), + ("test_version", "0.4.3"), + ("engine_version", "3.28.0"), + ], +) +def test_oonidata_list_observations_with_filters( + client, filter_name, filter_value, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params[filter_name] = filter_value + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result[filter_name] == filter_value, result + + +def test_oonidata_list_observations_filtering_by_probe_asn_as_a_string_with_since_and_until( + client, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + probe_asn = 1267 + params["probe_asn"] = "AS" + str(probe_asn) + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for result in json["results"]: + assert result["probe_asn"] == probe_asn, result + + +def test_oonidata_list_observations_order_default( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for i in range(1, len(json["results"])): + assert "measurement_start_time" in json["results"][i], json["results"][i] + previous_date = json["results"][i - 1]["measurement_start_time"] + current_date = json["results"][i]["measurement_start_time"] + assert ( + previous_date >= current_date + ), f"The dates are not ordered: {previous_date} < {current_date}" + + +def test_oonidata_list_observations_order_asc( + client, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params["order"] = "ASC" + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for i in range(1, len(json["results"])): + assert "measurement_start_time" in json["results"][i], json["results"][i] + previous_date = json["results"][i - 1]["measurement_start_time"] + current_date = json["results"][i]["measurement_start_time"] + assert ( + previous_date <= current_date + ), f"The dates are not ordered: {previous_date} > {current_date}" + + +@pytest.mark.parametrize( + "field, order", + [ + ("input", "asc"), + ("probe_cc", "asc"), + ("probe_asn", "asc"), + ("test_name", "asc"), + ("input", "desc"), + ("probe_cc", "desc"), + ("probe_asn", "desc"), + ("test_name", "desc"), + ], +) +def test_oonidata_list_observations_order_by_field( + client, field, order, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params["order_by"] = field + params["order"] = order + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) > 0 + for i in range(1, len(json["results"])): + assert field in json["results"][i], json["results"][i] + previous = json["results"][i - 1][field] + current = json["results"][i][field] + if order == "asc": + assert ( + previous <= current + ), f"The {field} values are not ordered in ascending order: {previous} > {current}" + else: + assert ( + previous >= current + ), f"The {field} values are not ordered in descending order: {previous} < {current}" + + +def test_oonidata_list_observations_limit_by_default( + client, params_since_and_until_with_two_days +): + response = client.get(route, params=params_since_and_until_with_two_days) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 100 + + +def test_oonidata_list_observations_with_limit_and_offset( + client, params_since_and_until_with_two_days +): + params = params_since_and_until_with_two_days + params["limit"] = 10 + + response = client.get(route, params=params) + + json = response.json() + assert isinstance(json["results"], list), json + assert len(json["results"]) == 10