This is an automated email from the ASF dual-hosted git repository. morningman pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/doris-cli.git
commit a0c659621f6f554551a70a1403ed6f98a156701d Author: Mingyu Chen (Rayner) <[email protected]> AuthorDate: Fri May 29 16:44:27 2026 +0800 test: add end-to-end test harness for doriscli Black-box harness that drives the built `doriscli` binary against a real, already-deployed Doris cluster and reports pass/fail/skip per command. - start-testing.sh: builds the binary, probes connectivity, seeds a throwaway doriscli_selftest database, runs the suites, drops the database, and prints a summary; exits non-zero only when a test fails. - Suites: cli (offline argument contract), auth (config-file mode + stateless mode), sql, tablet, profile — plus the offline `cargo test` unit suite. - SKIP state for cluster-side preconditions (FE HTTP profile API, audit_log) so optional features are not reported as failures. - Connection from tests/e2e/cluster.env or CLI flags; assertions are jq-based. Also ignore /target/ (was untracked) plus the harness's local cluster.env and results/ logs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --- .gitignore | 7 ++ start-testing.sh | 260 ++++++++++++++++++++++++++++++++++++++++++ tests/e2e/README.md | 128 +++++++++++++++++++++ tests/e2e/cluster.env.example | 24 ++++ tests/e2e/data.sh | 128 +++++++++++++++++++++ tests/e2e/lib.sh | 207 +++++++++++++++++++++++++++++++++ tests/e2e/suite_auth.sh | 96 ++++++++++++++++ tests/e2e/suite_cli.sh | 36 ++++++ tests/e2e/suite_profile.sh | 96 ++++++++++++++++ tests/e2e/suite_sql.sh | 68 +++++++++++ tests/e2e/suite_tablet.sh | 53 +++++++++ 11 files changed, 1103 insertions(+) diff --git a/.gitignore b/.gitignore index e43b0f9..f967642 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ .DS_Store + +# Rust build artifacts +/target/ + +# e2e test harness: local connection config + run logs +/tests/e2e/cluster.env +/tests/e2e/results/ diff --git a/start-testing.sh b/start-testing.sh new file mode 100755 index 0000000..f2bfcb8 --- /dev/null +++ b/start-testing.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# start-testing.sh — end-to-end test runner for the doriscli binary. +# +# Point it at a deployed Doris cluster and it builds doriscli, exercises every +# command against the cluster, and prints which tests passed / failed / skipped. +# +# ./start-testing.sh --host fe.example.com --port 9030 --http-port 8030 \ +# --user root --password 'secret' +# +# or put the connection in tests/e2e/cluster.env (see cluster.env.example) and run: +# +# ./start-testing.sh +# +# Exit code is 0 only when nothing FAILED (skips do not fail the run). +# Run `./start-testing.sh --help` for all options. + +# Re-exec under bash if invoked as `sh start-testing.sh` on a system where /bin/sh +# is not bash (the harness uses bash arrays/locals). +if [ -z "${BASH_VERSION:-}" ]; then exec bash "$0" "$@"; fi + +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$SCRIPT_DIR" +E2E_DIR="$SCRIPT_DIR/tests/e2e" + +# ---- defaults ------------------------------------------------------------ +RUN_UNIT=1 +KEEP_DB=0 +NO_BUILD=0 +BUILD_PROFILE="debug" +LIST_ONLY=0 +ALL_SUITES="cli auth sql tablet profile" +ONLY_SUITES="" +CONFIG_FILE="$E2E_DIR/cluster.env" +CONFIG_FILE_EXPLICIT=0 + +usage() { + cat <<'EOF' +Usage: ./start-testing.sh [connection options] [runner options] + +Connection (overrides tests/e2e/cluster.env; can also come from that file): + --host <h> FE host (required, unless set in cluster.env) + --port <n> MySQL/query port (default 9030) + --http-port <n> FE HTTP port (default 8030; cloud often 8080) + --user <u> MySQL user (default root) + --password <p> MySQL password (default empty) + --init-sql <sql> Run after connect, e.g. 'USE @<compute_group>' for cloud + --socks5 <u:p@h:p> Route through a SOCKS5 proxy (BYOC) + --db <name> Self-test database name (default doriscli_selftest) + --rows <n> Rows to load into events (default 2000) + +Runner: + --config <file> Connection file to source (default tests/e2e/cluster.env) + --bin <path> Use a prebuilt doriscli instead of building + --release Build the release binary instead of debug + --no-build Do not build; use an existing target/ binary or --bin + --no-unit Skip the offline `cargo test` unit suite + --only "<suites>" Run only these suites (space/comma list): cli auth sql tablet profile + --keep Do not drop the self-test database afterwards + --list List the suites and exit + -h, --help Show this help + +Examples: + ./start-testing.sh --host 127.0.0.1 --user root --password '' + ./start-testing.sh --only "cli sql" --no-unit + ./start-testing.sh --host fe --http-port 8080 --init-sql 'USE @my_cg' # cloud +EOF +} + +# ---- parse args ---------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --host) FLAG_HOST="$2"; shift 2;; + --port) FLAG_PORT="$2"; shift 2;; + --http-port) FLAG_HTTP_PORT="$2"; shift 2;; + --user) FLAG_USER="$2"; shift 2;; + --password) FLAG_PASSWORD="$2"; shift 2;; + --init-sql) FLAG_INIT_SQL="$2"; shift 2;; + --socks5) FLAG_SOCKS5="$2"; shift 2;; + --db) FLAG_DB="$2"; shift 2;; + --rows) FLAG_ROWS="$2"; shift 2;; + --config) CONFIG_FILE="$2"; CONFIG_FILE_EXPLICIT=1; shift 2;; + --bin) BIN_OVERRIDE="$2"; shift 2;; + --release) BUILD_PROFILE="release"; shift;; + --no-build) NO_BUILD=1; shift;; + --no-unit) RUN_UNIT=0; shift;; + --only) ONLY_SUITES="$2"; shift 2;; + --keep) KEEP_DB=1; shift;; + --list) LIST_ONLY=1; shift;; + -h|--help) usage; exit 0;; + *) echo "Unknown option: $1" >&2; echo; usage; exit 2;; + esac +done + +if [ "$LIST_ONLY" = 1 ]; then + echo "Available suites: $ALL_SUITES" + echo " cli offline CLI contract (version/help/argument errors)" + echo " auth auth add/list/status/remove, use, stateless mode (needs cluster)" + echo " sql query execution, -f, --set, --no-cache, --profile, formats, errors" + echo " tablet model/bucket/health/detail (needs cluster + seeded data)" + echo " profile list/get/full/raw/diff/history (needs cluster; some auto-SKIP)" + echo "Plus an offline 'cargo test' unit suite unless --no-unit." + exit 0 +fi + +# ---- resolve connection (CLI flag > cluster.env > default) --------------- +if [ -f "$CONFIG_FILE" ]; then + # shellcheck disable=SC1090 + . "$CONFIG_FILE" +elif [ "$CONFIG_FILE_EXPLICIT" = 1 ]; then + echo "Config file not found: $CONFIG_FILE" >&2; exit 2 +fi + +CFG_HOST="${FLAG_HOST:-${DORIS_TEST_HOST:-}}" +CFG_PORT="${FLAG_PORT:-${DORIS_TEST_PORT:-9030}}" +CFG_HTTP_PORT="${FLAG_HTTP_PORT:-${DORIS_TEST_HTTP_PORT:-8030}}" +CFG_USER="${FLAG_USER:-${DORIS_TEST_USER:-root}}" +CFG_PASSWORD="${FLAG_PASSWORD-${DORIS_TEST_PASSWORD-}}" +CFG_INIT_SQL="${FLAG_INIT_SQL-${DORIS_TEST_INIT_SQL-}}" +CFG_SOCKS5="${FLAG_SOCKS5-${DORIS_TEST_SOCKS5-}}" +CFG_DB="${FLAG_DB:-${DORIS_TEST_DB:-doriscli_selftest}}" +ROWS="${FLAG_ROWS:-${DORIS_TEST_ROWS:-2000}}" + +# Which suites, and do any of them require the cluster? +SUITES="${ONLY_SUITES:-$ALL_SUITES}" +SUITES="$(printf '%s' "$SUITES" | tr ',' ' ')" +wants() { case " $SUITES " in *" $1 "*) return 0;; *) return 1;; esac; } + +needs_cluster=0 +for s in auth sql tablet profile; do wants "$s" && needs_cluster=1; done + +if [ "$needs_cluster" = 1 ] && [ -z "$CFG_HOST" ]; then + echo "No cluster connection provided." >&2 + echo "Pass --host ... (see --help) or create $E2E_DIR/cluster.env" >&2 + echo "from cluster.env.example. (Or run only the offline suite: --only cli)" >&2 + exit 2 +fi + +# ---- preflight: dependencies + binary ------------------------------------ +command -v jq >/dev/null 2>&1 || { echo "Missing dependency: jq (install jq and retry)." >&2; exit 2; } + +if [ -n "${BIN_OVERRIDE:-}" ]; then + BIN="$BIN_OVERRIDE" +else + REL="$REPO_ROOT/target/release/doriscli" + DBG="$REPO_ROOT/target/debug/doriscli" + if [ "$NO_BUILD" = 1 ]; then + if [ -x "$REL" ]; then BIN="$REL" + elif [ -x "$DBG" ]; then BIN="$DBG" + else echo "--no-build set but no binary in target/. Build it or pass --bin." >&2; exit 2; fi + else + command -v cargo >/dev/null 2>&1 || { echo "cargo not found; pass --bin <path> or install Rust." >&2; exit 2; } + echo "Building doriscli ($BUILD_PROFILE) — first build may take a few minutes ..." + if [ "$BUILD_PROFILE" = "release" ]; then + cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" || { echo "build failed" >&2; exit 2; } + BIN="$REL" + else + cargo build --manifest-path "$REPO_ROOT/Cargo.toml" || { echo "build failed" >&2; exit 2; } + BIN="$DBG" + fi + fi +fi +[ -x "$BIN" ] || { echo "doriscli binary not found/executable: $BIN" >&2; exit 2; } + +# ---- working dirs + logging ---------------------------------------------- +RESULTS_DIR="$E2E_DIR/results" +mkdir -p "$RESULTS_DIR" +TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$RESULTS_DIR/run-$TS.log" +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/doriscli-e2e.XXXXXX")" +ISOLATED_HOME="$WORKDIR/home" +mkdir -p "$ISOLATED_HOME" + +# Export for the helpers/suites. +export BIN CFG_HOST CFG_PORT CFG_HTTP_PORT CFG_USER CFG_PASSWORD CFG_INIT_SQL CFG_SOCKS5 CFG_DB ROWS +export LOG_FILE WORKDIR ISOLATED_HOME REPO_ROOT KEEP_DB HTTP_OK + +# ---- source helpers + suites --------------------------------------------- +# shellcheck source=tests/e2e/lib.sh +. "$E2E_DIR/lib.sh" +# shellcheck source=tests/e2e/data.sh +. "$E2E_DIR/data.sh" +for f in cli auth sql tablet profile; do + # shellcheck disable=SC1090 + . "$E2E_DIR/suite_$f.sh" +done + +# Offline unit suite (defined here; needs cargo + source tree). +suite_unit() { + suite_banner "cargo unit tests (offline)" + if ! command -v cargo >/dev/null 2>&1; then + record_skip "cargo test (unit)" "cargo not found" + return + fi + log " running 'cargo test' (output in the run log) ..." + printf '\n===== cargo test =====\n' >>"$LOG_FILE" + if cargo test --manifest-path "$REPO_ROOT/Cargo.toml" >>"$LOG_FILE" 2>&1; then + record_pass "cargo test (unit)" + else + record_fail "cargo test (unit)" "see the 'cargo test' section in $LOG_FILE" + fi +} + +# ---- teardown trap (drops the self-test DB, cleans temp) ----------------- +cleanup() { + type teardown_data >/dev/null 2>&1 && teardown_data + rm -rf "$WORKDIR" 2>/dev/null || true +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +# ---- header -------------------------------------------------------------- +HTTP_OK=0 +log "${C_BOLD}doriscli end-to-end test run${C_RESET} ($TS)" +log " binary : $BIN" +log " version: $("$BIN" --version 2>/dev/null)" +unit_note=""; [ "$RUN_UNIT" = 1 ] && unit_note=" (+cargo unit)" +log " suites : $SUITES$unit_note" +log " log : $LOG_FILE" +if [ "$needs_cluster" = 1 ]; then + log " cluster: $CFG_USER@$CFG_HOST:$CFG_PORT (http :$CFG_HTTP_PORT) db=$CFG_DB" + [ -n "$CFG_INIT_SQL" ] && log " init-sql: $CFG_INIT_SQL" +fi + +# ---- offline suites first (fast feedback, no cluster) -------------------- +[ "$RUN_UNIT" = 1 ] && suite_unit +wants cli && suite_cli + +# ---- connectivity probe (gates the cluster suites; sets HTTP_OK) --------- +if [ "$needs_cluster" = 1 ]; then + suite_banner "Connectivity probe" + DCLI_STATELESS=1 _run_dcli --format json auth status + MS="$(jget '.mysql_status')" + if [ "$MS" != "connected" ]; then + log "${C_RED}${C_BOLD}Cannot connect to the cluster — aborting cluster suites.${C_RESET}" + log " mysql_status: $MS" + log " stderr: $(_oneline "$ERR")" + log " Verify host/port/user/password. For a cloud/storage-compute cluster, set" + log " DORIS_TEST_INIT_SQL='USE @<compute_group>' and --http-port (often 8080)." + print_summary + exit 1 + fi + [ "$(jget '.http_status')" = "connected" ] && HTTP_OK=1 + log " ${C_GREEN}connected${C_RESET} version=$(jget '.doris_version') http_status=$(jget '.http_status') backends=$(jget '.backends|length') (HTTP_OK=$HTTP_OK)" +fi + +# ---- cluster suites ------------------------------------------------------ +wants auth && suite_auth + +if wants sql || wants tablet || wants profile; then + setup_data +fi +wants sql && suite_sql +wants tablet && suite_tablet +wants profile && suite_profile + +# ---- summary + exit code ------------------------------------------------- +print_summary +[ "$N_FAIL" -eq 0 ] diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..96e065b --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,128 @@ +# doriscli end-to-end test harness + +Black-box tests that drive the **built `doriscli` binary** against a **real, +already-deployed Doris cluster** and report which commands pass, fail, or are +skipped. Everything goes through `doriscli` itself — there is no separate MySQL +client and no mocking — so a green run means the CLI genuinely works end to end +against that cluster. + +## TL;DR + +```bash +# from the repo root (doris-cli/) +cp tests/e2e/cluster.env.example tests/e2e/cluster.env +$EDITOR tests/e2e/cluster.env # fill in host/port/user/password +./start-testing.sh +``` + +or pass the connection on the command line: + +```bash +./start-testing.sh --host fe.example.com --port 9030 --http-port 8030 \ + --user root --password 'secret' +``` + +The runner builds `doriscli`, probes connectivity, creates a throwaway +`doriscli_selftest` database, runs every suite, **drops the database**, and +prints a summary. Exit code is `0` only if nothing **failed** (skips don't fail +the run). Full per-command output for the run is saved under +`tests/e2e/results/run-<timestamp>.log`. + +Requirements: `bash`, `jq`, and either a Rust toolchain (to build) or a prebuilt +binary via `--bin`. + +## Result semantics + +| Status | Meaning | +|---|---| +| **PASS** | The command behaved exactly as the contract requires. | +| **FAIL** | The command misbehaved: wrong exit code, malformed JSON, or a missing/!= expected field. **These are the ones to look at.** | +| **SKIP** | A *precondition of the cluster* (not a bug in the CLI) was absent, so the test couldn't run — e.g. the FE HTTP API isn't reachable, or `audit_log` isn't enabled. Reported separately; never fails the run. | + +The SKIP state exists because several `doriscli` features depend on optional +cluster capabilities. Treating "audit_log disabled" as a CLI failure would cry +wolf; treating it as a silent pass would hide untested surface. So it's its own +bucket, and the summary lists exactly what was skipped and why. + +## What each suite covers + +Run a subset with `--only "<suites>"`; list them with `--list`. + +### `cargo test` (offline, unless `--no-unit`) +The crate's in-tree unit tests — primarily the profile-text parsers +(`section_parser`, `fragment_parser`, `operator_parser`, `value_parser`). No +cluster needed. + +### `cli` — argument contract (offline) +`--version` / `-V`, `--help` (usage + subcommand listing), and the error paths: +`sql` with no query, unknown subcommand, `tablet` with no table, `profile` with +no action, and an unknown flag. Verifies non-zero exit on misuse. + +### `auth` — connection management + stateless mode (needs cluster) +Uses an **isolated `$HOME`**, so your real `~/.doris` is never touched. +- `auth list` on an empty config → empty-list shape. +- `auth add` → saves an env (first one becomes default); `auth list` reflects it. +- `auth status` → **connects over MySQL**; asserts `.mysql_status == "connected"` + (the command always exits 0, so the field is the real connectivity check). +- `use` / `use <name>` → show and switch the default env. +- `auth add --mysql mysql://…` → URI parsing (skipped if the password isn't + URI-safe). +- `auth remove` → deletes an env. +- **Stateless mode** (`DORIS_HOST`+`DORIS_USER`): `auth add` is *refused*, + `auth status` still connects from env vars, and **no files are written to + disk** (verified against a pristine HOME). + +### `sql` — execution (needs cluster) +The JSON envelope (`query_id`, `exec_time_ms`, `rows_returned`, `columns[]`, +`rows[]`), type mapping (string vs number), `-f <file>`, `--set` (single and +repeated), `--no-cache`, `--profile` (yields a `query_id`), `--format +table`/`csv`, empty result sets, a `COUNT(*)` over the loaded data, and the +error path (`SQL error:` on a bad reference, non-zero exit). + +### `tablet` — bucket / tablet analysis (needs cluster + seeded data) +Overview: `model`, `bucket_type`, `bucket_key`, `bucket_count`, `sort_key`, +`total_rows`; the `health.tablet_skew` summary; `columns[].ndv` (SKIP if column +stats weren't collected). `UNIQUE` model detection on a second table. +`--detail` (per-tablet + per-backend) and `--detail --partition` (narrowed to +one partition). Negative: a missing table exits non-zero. + +### `profile` — query profiles (needs cluster; HTTP-gated parts auto-SKIP) +`profile list` and `list --active` (arrays); `profile get <id>` summary (works +via the SQL fallback even without HTTP); `get --full`, `get --raw`, and `diff` +(**require the FE HTTP profile API → SKIP if `http_status != connected`**); +`profile history` (**requires `__internal_schema.audit_log` → SKIP if absent**); +and a negative case (unknown query id exits non-zero). + +## The seed data + +`setup_data` creates two tables in `doriscli_selftest` (DDL is mode-agnostic; +`replication_num=1` works on self-hosted and is required in cloud mode): + +- `events` — `DUPLICATE` model, range-partitioned by `event_date` into two + partitions (Jan/Feb 2024), `DISTRIBUTED BY HASH(user_id) BUCKETS 4` → 8 + tablets. Loaded with `--rows` (default 2000) rows via a generated `INSERT` + fed through `sql -f` (dates computed server-side by `DATE_ADD`, so no + dependency on any particular Doris version). `ANALYZE TABLE … WITH SYNC` runs + best-effort so `tablet`'s `columns[].ndv` is populated. +- `dim_users` — `UNIQUE` model, 2 buckets, a handful of rows. + +The database is dropped on exit (even on Ctrl-C) unless you pass `--keep`. + +## Options + +See `./start-testing.sh --help`. Highlights: `--only`, `--no-unit`, `--keep`, +`--release`, `--bin <path>`, `--no-build`, `--rows <n>`, `--config <file>`. + +## Notes on cluster prerequisites + +- **MySQL/query port** (default 9030) is required for everything. +- **FE HTTP port** (default 8030; cloud often **8080**) enables `auth status`'s + HTTP probe and the `profile get --full/--raw` and `profile diff` paths. + Without it those tests SKIP rather than fail. +- **Cloud / storage-compute**: set `DORIS_TEST_INIT_SQL='USE @<compute_group>'` + so queries run against a live compute group; otherwise setup fails early with + a hint. +- The test user needs `CREATE`/`DROP`/`INSERT` on the self-test database (and + `SELECT` on `information_schema`). For full `profile` coverage the cluster + should retain profiles (`enable_profile` is set per-query by `--profile`) and, + for `profile history`, have the audit log enabled. diff --git a/tests/e2e/cluster.env.example b/tests/e2e/cluster.env.example new file mode 100644 index 0000000..4926be0 --- /dev/null +++ b/tests/e2e/cluster.env.example @@ -0,0 +1,24 @@ +# Connection for ./start-testing.sh +# +# Copy this file to tests/e2e/cluster.env and fill in your cluster. +# (cluster.env is gitignored so your credentials never get committed.) +# Any value here can be overridden by a command-line flag of the same name. + +DORIS_TEST_HOST=127.0.0.1 +DORIS_TEST_PORT=9030 +DORIS_TEST_HTTP_PORT=8030 +DORIS_TEST_USER=root +DORIS_TEST_PASSWORD= + +# Self-test database (created and dropped by the run) + rows loaded into events. +DORIS_TEST_DB=doriscli_selftest +DORIS_TEST_ROWS=2000 + +# --- Cloud / storage-compute clusters --- +# Most cloud clusters require selecting a compute group before any query runs, +# and expose the FE HTTP API on 8080 rather than 8030: +# DORIS_TEST_INIT_SQL=USE @my_compute_group +# DORIS_TEST_HTTP_PORT=8080 + +# --- Reaching a bastioned / BYOC cluster through a SOCKS5 proxy --- +# DORIS_TEST_SOCKS5=user:pass@proxy-host:1080 diff --git a/tests/e2e/data.sh b/tests/e2e/data.sh new file mode 100644 index 0000000..66e4682 --- /dev/null +++ b/tests/e2e/data.sh @@ -0,0 +1,128 @@ +# shellcheck shell=bash +# tests/e2e/data.sh — create / load / drop the self-test database. +# +# Everything runs THROUGH doriscli itself (no external mysql client), so loading +# data also exercises `sql` (incl. the -f file path) against the real cluster. +# All DDL is mode-agnostic (replication_num=1 works on self-hosted single/multi-BE +# and is required in cloud / storage-compute mode). + +SETUP_DB_CREATED=0 + +# Abort the whole run with an actionable hint. Teardown still fires via the trap. +die_setup() { + log "" + log "${C_RED}${C_BOLD}SETUP FAILED${C_RESET} — ${1}" + log " ${C_DIM}stderr: $(_oneline "$ERR")${C_RESET}" + log "" + log "Likely causes:" + log " • Wrong host/port/user/password — check your cluster.env / flags." + log " • Cloud (storage-compute) cluster needs a compute group selected first:" + log " set DORIS_TEST_INIT_SQL='USE @<your_compute_group>' in cluster.env" + log " • The user lacks CREATE / DROP / LOAD / INSERT privileges." + log " • FE query port unreachable from this host." + exit 1 +} + +# Emit a single multi-row INSERT for the events table (no `numbers` TVF dependency; +# dates are computed server-side by DATE_ADD so it works on any Doris version). +_gen_events_sql() { + local db="$1" n="${2:-2000}" + awk -v db="$db" -v n="$n" 'BEGIN { + split("click,view,purchase,signup", t, ","); + printf "INSERT INTO `%s`.`events` (event_date,user_id,event_type,amount,detail) VALUES\n", db; + for (i = 0; i < n; i++) { + day = i % 59; # 0..58 days -> spans Jan + Feb 2024 (both partitions) + uid = i % 1000; # cardinality ~1000 for user_id + typ = t[(i % 4) + 1]; + amt = (i % 100) + 0.5; + sep = (i == n - 1) ? ";" : ","; + printf "(DATE_ADD(\x272024-01-01\x27, INTERVAL %d DAY),%d,\x27%s\x27,%.2f,\x27detail_%d\x27)%s\n", + day, uid, typ, amt, i, sep; + } + }' +} + +setup_data() { + suite_banner "SETUP (database '$CFG_DB' on the target cluster)" + local db="$CFG_DB" + + # Fresh start: drop a leftover db from a previous interrupted run. + _run_dcli --format json sql "DROP DATABASE IF EXISTS \`$db\`" + [ "$RC" -eq 0 ] || die_setup "could not DROP a pre-existing '$db' (connectivity / privileges?)" + + _run_dcli --format json sql "CREATE DATABASE \`$db\`" + [ "$RC" -eq 0 ] || die_setup "CREATE DATABASE \`$db\` failed" + SETUP_DB_CREATED=1 + log " created database $db" + + # --- events: DUPLICATE, range-partitioned, HASH bucketed (drives `tablet`) --- + _run_dcli --format json sql "CREATE TABLE \`$db\`.\`events\` ( + event_date DATE NOT NULL, + user_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + amount DECIMAL(10,2), + detail STRING + ) + DUPLICATE KEY(event_date, user_id) + PARTITION BY RANGE(event_date) ( + PARTITION p20240101 VALUES [('2024-01-01'), ('2024-02-01')), + PARTITION p20240201 VALUES [('2024-02-01'), ('2024-03-01')) + ) + DISTRIBUTED BY HASH(user_id) BUCKETS 4 + PROPERTIES ('replication_num' = '1')" + [ "$RC" -eq 0 ] || die_setup "CREATE TABLE events failed" + log " created table events (DUPLICATE, 2 partitions x 4 buckets)" + + # --- dim_users: UNIQUE model (so `tablet` model detection is exercised twice) --- + _run_dcli --format json sql "CREATE TABLE \`$db\`.\`dim_users\` ( + user_id BIGINT NOT NULL, + name VARCHAR(64), + level INT + ) + UNIQUE KEY(user_id) + DISTRIBUTED BY HASH(user_id) BUCKETS 2 + PROPERTIES ('replication_num' = '1')" + [ "$RC" -eq 0 ] || die_setup "CREATE TABLE dim_users failed" + log " created table dim_users (UNIQUE, 2 buckets)" + + # --- load events via a generated .sql file (exercises `sql -f`) --- + local events_sql="$WORKDIR/events_insert.sql" + _gen_events_sql "$db" "${ROWS:-2000}" >"$events_sql" + _run_dcli --format json sql -f "$events_sql" + [ "$RC" -eq 0 ] || die_setup "loading events via 'sql -f' failed" + log " loaded ${ROWS:-2000} rows into events (via sql -f)" + + # --- load dim_users (small inline INSERT) --- + _run_dcli --format json sql "INSERT INTO \`$db\`.\`dim_users\` (user_id,name,level) VALUES + (1,'alice',3),(2,'bob',1),(3,'carol',2),(4,'dave',5),(5,'erin',4), + (6,'frank',2),(7,'grace',1),(8,'heidi',3),(9,'ivan',2),(10,'judy',4)" + [ "$RC" -eq 0 ] || die_setup "loading dim_users failed" + log " loaded 10 rows into dim_users" + + # --- column stats so tablet's columns[].ndv is populated (best effort) --- + # ANALYZE may be async/unavailable on some versions; failure is not fatal — + # the tablet suite degrades the column-stats check to SKIP if stats are absent. + _run_dcli --format json sql "ANALYZE TABLE \`$db\`.\`events\` WITH SYNC" + if [ "$RC" -eq 0 ]; then + log " ran ANALYZE TABLE events WITH SYNC (column stats collected)" + else + log " ${C_YELLOW}note${C_RESET}: ANALYZE TABLE events did not succeed; columns[].ndv may be empty" + fi + + log "${C_GREEN} setup complete${C_RESET}" +} + +teardown_data() { + # Called from the EXIT trap, so guard everything and never abort. + [ "${SETUP_DB_CREATED:-0}" = "1" ] || return 0 + if [ "${KEEP_DB:-0}" = "1" ]; then + log "" + log "${C_YELLOW}--keep set: leaving database '$CFG_DB' in place.${C_RESET}" + return 0 + fi + log "" + log "Teardown: dropping database '$CFG_DB' ..." + _run_dcli --format json sql "DROP DATABASE IF EXISTS \`$CFG_DB\`" + if [ "$RC" -eq 0 ]; then log " dropped $CFG_DB" + else log " ${C_YELLOW}warning${C_RESET}: could not drop $CFG_DB — drop it manually. ($(_oneline "$ERR"))"; fi +} diff --git a/tests/e2e/lib.sh b/tests/e2e/lib.sh new file mode 100644 index 0000000..7043a64 --- /dev/null +++ b/tests/e2e/lib.sh @@ -0,0 +1,207 @@ +# shellcheck shell=bash +# tests/e2e/lib.sh — shared helpers for the doriscli end-to-end test harness. +# +# Sourced by start-testing.sh (which runs under bash). Provides: +# - result tracking (PASS / FAIL / SKIP) and the final summary +# - _run_dcli: the single place that invokes the doriscli binary, in either +# * stateless mode (DORIS_HOST/USER/... exported — no config files touched), or +# * file mode (DORIS_* unset, isolated $HOME — exercises ~/.doris config) +# - assertion helpers built on jq: expect_json / expect_ok / expect_err / skip +# +# Design notes that the assertions depend on (verified against doriscli source): +# - Every command prints a bare JSON value to stdout and exits 0 on success, +# non-zero on error (error text on stderr). +# - `auth status` ALWAYS exits 0; real connectivity is in the .mysql_status field. +# - profile get --full/--raw and profile diff REQUIRE the FE HTTP API; the default +# `profile get` summary has a SQL fallback; `profile history` needs audit_log. +# Those are gated on HTTP_OK / detected at runtime and recorded as SKIP, not FAIL. + +# ---- result state -------------------------------------------------------- +N_PASS=0 +N_FAIL=0 +N_SKIP=0 +FAILED_NAMES=() +SKIPPED_NAMES=() + +# Populated by _run_dcli on every call: +OUT="" # captured stdout +ERR="" # captured stderr +RC=0 # exit code + +# ---- colors (disabled when not a tty or NO_COLOR set) -------------------- +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_YELLOW=$'\033[33m' + C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_RESET=$'\033[0m' +else + C_GREEN=""; C_RED=""; C_YELLOW=""; C_BOLD=""; C_DIM=""; C_RESET="" +fi + +# ---- logging ------------------------------------------------------------- +# log: to console AND the run log file. logfile_only: just the file. +log() { printf '%s\n' "$*"; printf '%s\n' "$*" >>"$LOG_FILE" 2>/dev/null || true; } +logfile_only() { printf '%s\n' "$*" >>"$LOG_FILE" 2>/dev/null || true; } + +# Collapse a (possibly multi-line) string to a single, truncated line for the +# console summary. Full untruncated output always lives in $LOG_FILE. +_oneline() { + printf '%s' "$1" | tr '\n\t' ' ' | cut -c1-240 +} + +# ---- result recorders ---------------------------------------------------- +record_pass() { + N_PASS=$((N_PASS + 1)) + log " ${C_GREEN}PASS${C_RESET} $1" +} +record_fail() { + N_FAIL=$((N_FAIL + 1)) + FAILED_NAMES+=("$1") + log " ${C_RED}FAIL${C_RESET} $1" + [ -n "$2" ] && log " ${C_DIM}↳ $(_oneline "$2")${C_RESET}" +} +record_skip() { + N_SKIP=$((N_SKIP + 1)) + SKIPPED_NAMES+=("$1") + log " ${C_YELLOW}SKIP${C_RESET} $1" + [ -n "$2" ] && log " ${C_DIM}↳ $(_oneline "$2")${C_RESET}" +} + +# Print a section banner for a suite. +suite_banner() { + log "" + log "${C_BOLD}━━ $1 ━━${C_RESET}" +} + +# ---- the doriscli runner ------------------------------------------------- +# _run_dcli <args...> +# Runs $BIN with the configured global flags (--init-sql / --socks5) prepended, +# in stateless mode by default. Set DCLI_STATELESS=0 before calling to exercise +# the file-based config path (DORIS_* unset, $HOME isolated to $ISOLATED_HOME). +# Captures stdout->OUT, stderr->ERR, exit code->RC. Logs the invocation. +_run_dcli() { + local globals=() + [ -n "${CFG_INIT_SQL:-}" ] && globals+=(--init-sql "$CFG_INIT_SQL") + [ -n "${CFG_SOCKS5:-}" ] && globals+=(--socks5 "$CFG_SOCKS5") + + local errf; errf="$(mktemp "${TMPDIR:-/tmp}/dcli-err.XXXXXX")" + + if [ "${DCLI_STATELESS:-1}" = "1" ]; then + OUT=$(HOME="$ISOLATED_HOME" \ + DORIS_HOST="$CFG_HOST" \ + DORIS_PORT="$CFG_PORT" \ + DORIS_HTTP_PORT="$CFG_HTTP_PORT" \ + DORIS_USER="$CFG_USER" \ + DORIS_PASSWORD="$CFG_PASSWORD" \ + "$BIN" "${globals[@]}" "$@" 2>"$errf") + RC=$? + else + # File mode: scrub any DORIS_* the caller's shell may carry, so stateless + # mode is NOT triggered, and point HOME at the isolated config dir. + OUT=$(HOME="$ISOLATED_HOME" \ + env -u DORIS_HOST -u DORIS_USER -u DORIS_PASSWORD \ + -u DORIS_PORT -u DORIS_HTTP_PORT -u DORIS_ENV -u DORIS_INIT_SQL \ + "$BIN" "${globals[@]}" "$@" 2>"$errf") + RC=$? + fi + + ERR=$(cat "$errf" 2>/dev/null); rm -f "$errf" + + { + printf '\n$ doriscli' + printf ' %q' "${globals[@]}" "$@" + printf ' [stateless=%s]\n' "${DCLI_STATELESS:-1}" + printf 'rc=%s\n--- stdout ---\n%s\n--- stderr ---\n%s\n' "$RC" "$OUT" "$ERR" + } >>"$LOG_FILE" 2>/dev/null || true +} + +# ---- assertion helpers --------------------------------------------------- +# All of these record exactly one PASS or FAIL (or SKIP) result. + +# expect_json "<name>" '<jq filter>' <doriscli args...> +# Appends --format json, expects exit 0 and a jq filter that evaluates truthy. +# Pass '' as the filter to only require valid JSON + exit 0. +expect_json() { + local name="$1" filter="$2"; shift 2 + _run_dcli --format json "$@" + if [ "$RC" -ne 0 ]; then + record_fail "$name" "exit=$RC; stderr: $ERR" + return + fi + if ! printf '%s' "$OUT" | jq -e . >/dev/null 2>&1; then + record_fail "$name" "stdout is not valid JSON: $OUT" + return + fi + if [ -n "$filter" ] && ! printf '%s' "$OUT" | jq -e "$filter" >/dev/null 2>&1; then + record_fail "$name" "assertion failed [ $filter ]; got: $OUT" + return + fi + record_pass "$name" +} + +# expect_ok "<name>" <doriscli args...> — just requires exit 0 (raw, no --format). +expect_ok() { + local name="$1"; shift + _run_dcli "$@" + if [ "$RC" -eq 0 ]; then record_pass "$name" + else record_fail "$name" "exit=$RC; stderr: $ERR"; fi +} + +# expect_err "<name>" <doriscli args...> — requires NON-zero exit. +# Optional: set EXPECT_ERR_MATCH to a substring required in stderr. +expect_err() { + local name="$1"; shift + _run_dcli "$@" + if [ "$RC" -eq 0 ]; then + record_fail "$name" "expected non-zero exit, got 0; stdout: $OUT" + return + fi + if [ -n "${EXPECT_ERR_MATCH:-}" ] && ! printf '%s' "$ERR" | grep -qiF "$EXPECT_ERR_MATCH"; then + record_fail "$name" "exit ok but stderr missing '$EXPECT_ERR_MATCH': $ERR" + return + fi + record_pass "$name" +} + +# expect_stdout_contains "<name>" "<substr>" <doriscli args...> +# Raw run (no --format), exit 0, and stdout contains substr (for --help, table/csv). +expect_stdout_contains() { + local name="$1" needle="$2"; shift 2 + _run_dcli "$@" + if [ "$RC" -ne 0 ]; then + record_fail "$name" "exit=$RC; stderr: $ERR" + return + fi + if printf '%s' "$OUT" | grep -qF "$needle"; then record_pass "$name" + else record_fail "$name" "stdout missing '$needle': $(_oneline "$OUT")"; fi +} + +# skip "<name>" "<reason>" — record a SKIP (precondition absent, not a bug). +skip() { record_skip "$1" "$2"; } + +# jget '<jq filter>' — echo a scalar pulled from the last OUT (raw -r). Empty on error. +jget() { printf '%s' "$OUT" | jq -r "$1" 2>/dev/null; } + +# ---- final summary ------------------------------------------------------- +print_summary() { + local total=$((N_PASS + N_FAIL + N_SKIP)) + log "" + log "${C_BOLD}══════════════════════ SUMMARY ══════════════════════${C_RESET}" + log " total: $total ${C_GREEN}pass: $N_PASS${C_RESET} ${C_RED}fail: $N_FAIL${C_RESET} ${C_YELLOW}skip: $N_SKIP${C_RESET}" + if [ "$N_SKIP" -gt 0 ]; then + log "" + log "${C_YELLOW}Skipped (precondition not met on this cluster — not a failure):${C_RESET}" + local s; for s in "${SKIPPED_NAMES[@]}"; do log " - $s"; done + fi + if [ "$N_FAIL" -gt 0 ]; then + log "" + log "${C_RED}${C_BOLD}Failed tests:${C_RESET}" + local f; for f in "${FAILED_NAMES[@]}"; do log " ${C_RED}✗${C_RESET} $f"; done + log "" + log "Full command output for every test is in:" + log " $LOG_FILE" + log "${C_RED}${C_BOLD}RESULT: FAIL ($N_FAIL failing)${C_RESET}" + else + log "" + log "Full run log: $LOG_FILE" + log "${C_GREEN}${C_BOLD}RESULT: PASS${C_RESET}" + fi +} diff --git a/tests/e2e/suite_auth.sh b/tests/e2e/suite_auth.sh new file mode 100644 index 0000000..86c8c75 --- /dev/null +++ b/tests/e2e/suite_auth.sh @@ -0,0 +1,96 @@ +# shellcheck shell=bash +# suite_auth — connection management via the ~/.doris config files (file mode), +# plus the stateless (env-var only) mode and its "never touches disk" guarantee. +# +# File-mode runs use an ISOLATED $HOME, so the real ~/.doris is never touched. +# `auth status` ALWAYS exits 0 in doriscli — so the real connectivity assertion +# is `.mysql_status == "connected"`, not the exit code. + +suite_auth() { + suite_banner "auth / use (config-file mode + stateless mode)" + + # Start from an empty config so the empty-list assertion is meaningful. + rm -rf "$ISOLATED_HOME/.doris" + DCLI_STATELESS=0 + + # Empty config: object with an empty environments array + a hint message. + expect_json "auth: list is empty on a fresh config" \ + '.environments == [] and (.message|type=="string")' \ + auth list + + # Add the cluster under test as 'selftest' (becomes the default env). + expect_json "auth: add saves an environment" \ + '.status=="added" and .name=="selftest" and (.http_probe|type=="object")' \ + auth add selftest --host "$CFG_HOST" --port "$CFG_PORT" \ + --http-port "$CFG_HTTP_PORT" --user "$CFG_USER" --password "$CFG_PASSWORD" + + # List now shows it, marked default. + expect_json "auth: list shows the added env as default" \ + '(type=="array") and (.[0].name=="selftest") and (.[0].default==true)' \ + auth list + + # status actually connects over MySQL — this is the live connectivity gate. + expect_json "auth: status connects to the cluster (mysql_status=connected)" \ + '.environment=="selftest" and .mysql_status=="connected"' \ + auth status + # Surface the version we saw, for the log. + DCLI_STATELESS=0 _run_dcli --format json auth status + log " ${C_DIM}cluster: version=$(jget '.doris_version') http=$(jget '.http_status') backends=$(jget '.backends|length')${C_RESET}" + + # `use` with no arg reports the current default. + expect_json "use: shows current environment" \ + '.current=="selftest"' \ + use + + # mysql:// URI form — only when the password is URI-safe (parser splits on : / @). + case "$CFG_PASSWORD" in + *:*|*@*|*/*) + skip "auth: add via mysql:// URI" "password contains : / or @ (not URI-safe to test)";; + *) + expect_json "auth: add via mysql:// URI parses host+port" \ + '.host=="'"$CFG_HOST"'" and .mysql_port=='"$CFG_PORT" \ + auth add uritest --mysql "mysql://$CFG_USER:$CFG_PASSWORD@$CFG_HOST:$CFG_PORT" + + # Switch default to it, then remove it, and confirm it's gone. + expect_json "use: switch to another environment" \ + '.status=="switched" and .environment=="uritest"' \ + use uritest + expect_json "auth: remove deletes an environment" \ + '.status=="removed" and .name=="uritest"' \ + auth remove uritest + expect_json "auth: removed env no longer listed" \ + 'any(.[]?; .name=="uritest") | not' \ + auth list + # restore default + DCLI_STATELESS=0 _run_dcli --format json use selftest + ;; + esac + + # ---- stateless mode (DORIS_HOST + DORIS_USER drive everything) ---- + DCLI_STATELESS=1 + + # Mutating config is refused in stateless mode (multi-tenant bastion safety). + EXPECT_ERR_MATCH="stateless" + expect_err "stateless: 'auth add' is refused" \ + auth add nope --host "$CFG_HOST" --user "$CFG_USER" + EXPECT_ERR_MATCH="" + + # ...but read/connect still works, purely from env vars. + expect_json "stateless: status connects from env vars only" \ + '.mysql_status=="connected"' \ + auth status + + # And it must NOT create any config files. Prove it with a pristine HOME. + local clean_home; clean_home="$WORKDIR/clean_home_$$" + local saved_home="$ISOLATED_HOME" + mkdir -p "$clean_home" + ISOLATED_HOME="$clean_home" + DCLI_STATELESS=1 _run_dcli --format json auth status + if [ -e "$clean_home/.doris" ]; then + record_fail "stateless: writes no files to disk" "created $clean_home/.doris" + else + record_pass "stateless: writes no files to disk" + fi + ISOLATED_HOME="$saved_home" + rm -rf "$clean_home" +} diff --git a/tests/e2e/suite_cli.sh b/tests/e2e/suite_cli.sh new file mode 100644 index 0000000..157443c --- /dev/null +++ b/tests/e2e/suite_cli.sh @@ -0,0 +1,36 @@ +# shellcheck shell=bash +# suite_cli — CLI-level contract. Pure argument parsing / help / error paths. +# These need no live cluster (clap handles --version/--help/bad-args before any +# connection). The one runtime path here — `sql` with no query — bails before +# connecting, so we run it in stateless mode to skip the auth lookup. + +suite_cli() { + suite_banner "CLI contract (offline: version / help / argument errors)" + DCLI_STATELESS=1 + + # --version / -V : clap prints "doriscli <ver>" and exits 0. + expect_stdout_contains "cli: --version prints the binary name" "doriscli" --version + expect_stdout_contains "cli: -V short flag works" "doriscli" -V + + # --help lists usage and the subcommands. + expect_stdout_contains "cli: --help shows Usage" "Usage" --help + expect_stdout_contains "cli: --help lists 'sql'" "sql" --help + expect_stdout_contains "cli: --help lists 'profile'" "profile" --help + + # `sql` with neither a query nor -f bails at runtime (before connecting). + EXPECT_ERR_MATCH="Provide a SQL query" + expect_err "cli: 'sql' with no query is rejected" sql + EXPECT_ERR_MATCH="" + + # Unknown subcommand → clap usage error, non-zero exit. + expect_err "cli: unknown subcommand is rejected" frobnicate-xyz + + # `tablet` requires a positional table name. + expect_err "cli: 'tablet' with no table is rejected" tablet + + # `profile` requires a subcommand (list/get/diff/history). + expect_err "cli: 'profile' with no action is rejected" profile + + # Unknown flag on a real subcommand → clap error. + expect_err "cli: unknown flag is rejected" sql "SELECT 1" --definitely-not-a-flag +} diff --git a/tests/e2e/suite_profile.sh b/tests/e2e/suite_profile.sh new file mode 100644 index 0000000..a9cd716 --- /dev/null +++ b/tests/e2e/suite_profile.sh @@ -0,0 +1,96 @@ +# shellcheck shell=bash +# suite_profile — query profile analysis. +# +# Dependency map (from the source): +# profile list / list --active MySQL only (always testable) +# profile get <id> (summary) HTTP, with SQL fallback (testable if --profile worked) +# profile get --full / --raw HTTP profile fetch ONLY -> SKIP if HTTP_OK=0 +# profile diff HTTP profile fetch ONLY -> SKIP if HTTP_OK=0 +# profile history __internal_schema.audit_log -> SKIP if absent +# +# HTTP_OK is set in preflight from `auth status`.http_status. + +suite_profile() { + suite_banner "profile (list / get / full / raw / diff / history)" + DCLI_STATELESS=1 + + # Generate two profiled queries and capture their ids. + DCLI_STATELESS=1 _run_dcli --format json sql \ + "SELECT event_type, COUNT(*) AS c FROM \`$CFG_DB\`.events GROUP BY event_type ORDER BY c DESC" --profile + local qid_a; qid_a="$(jget '.query_id')" + DCLI_STATELESS=1 _run_dcli --format json sql \ + "SELECT user_id, SUM(amount) AS s FROM \`$CFG_DB\`.events GROUP BY user_id ORDER BY s DESC LIMIT 10" --profile + local qid_b; qid_b="$(jget '.query_id')" + log " ${C_DIM}profiled query ids: A=$qid_a B=$qid_b (HTTP_OK=$HTTP_OK)${C_RESET}" + + # profile list -> JSON array. + DCLI_STATELESS=1 _run_dcli --format json profile list + if [ "$RC" -ne 0 ]; then + record_fail "profile: list returns an array" "exit=$RC; $ERR" + elif printf '%s' "$OUT" | jq -e 'type=="array"' >/dev/null 2>&1; then + record_pass "profile: list returns an array" + if [ -n "$qid_a" ] && printf '%s' "$OUT" | jq -e --arg q "$qid_a" 'any(.[]?; .query_id==$q)' >/dev/null 2>&1; then + record_pass "profile: list includes the just-profiled query" + else + record_skip "profile: list includes the just-profiled query" "not retained in SHOW QUERY PROFILE (eviction/timing)" + fi + else + record_fail "profile: list returns an array" "not a JSON array: $(_oneline "$OUT")" + fi + + # profile list --active -> JSON array (usually empty). + expect_json "profile: list --active returns an array" 'type=="array"' \ + profile list --active + + if [ -z "$qid_a" ]; then + skip "profile: get <id> summary" "no query_id captured (--profile produced none)" + skip "profile: get --full" "no query_id captured" + skip "profile: get --raw" "no query_id captured" + skip "profile: diff slow vs fast" "no query_id captured" + else + # Default get: summary object. Works even without HTTP via the SQL fallback. + expect_json "profile: get <id> returns a summary" \ + '(.summary|type=="object") and (.summary|has("query_id"))' \ + profile get "$qid_a" + + # --full and --raw require the FE HTTP profile API. + if [ "$HTTP_OK" = "1" ]; then + expect_json "profile: get --full returns the parsed tree" \ + '(.profile|type=="object") and (.operators|type=="array")' \ + profile get "$qid_a" --full + expect_json "profile: get --raw returns the raw profile text" \ + 'type=="string" and (contains("Summary"))' \ + profile get "$qid_a" --raw + else + skip "profile: get --full" "FE HTTP API not reachable (http_status != connected)" + skip "profile: get --raw" "FE HTTP API not reachable (http_status != connected)" + fi + + # diff needs both ids + HTTP. + if [ "$HTTP_OK" = "1" ] && [ -n "$qid_b" ]; then + expect_json "profile: diff compares two runs" \ + '(.slow|type=="object") and (.fast|type=="object") and (.time_ratio|type=="number")' \ + profile diff "$qid_a" "$qid_b" + else + skip "profile: diff slow vs fast" "needs FE HTTP API and two query ids" + fi + fi + + # profile history: depends on __internal_schema.audit_log being enabled. + DCLI_STATELESS=1 _run_dcli --format json profile history "events" --days 1 + if [ "$RC" -ne 0 ]; then + if printf '%s' "$ERR" | grep -qi "audit_log"; then + record_skip "profile: history reads audit_log" "audit_log not enabled/accessible on this cluster" + else + record_fail "profile: history reads audit_log" "exit=$RC; $ERR" + fi + elif printf '%s' "$OUT" | jq -e 'has("executions") and has("pattern")' >/dev/null 2>&1; then + record_pass "profile: history reads audit_log" + else + record_fail "profile: history reads audit_log" "unexpected shape: $(_oneline "$OUT")" + fi + + # Negative: an unknown query id cannot be fetched anywhere -> non-zero exit. + expect_err "profile: get on an unknown id errors" \ + profile get "00000000-0000-0000-0000-000000000000" +} diff --git a/tests/e2e/suite_sql.sh b/tests/e2e/suite_sql.sh new file mode 100644 index 0000000..8ee73a4 --- /dev/null +++ b/tests/e2e/suite_sql.sh @@ -0,0 +1,68 @@ +# shellcheck shell=bash +# suite_sql — SQL execution surface: the JSON envelope, -f files, --set session +# vars, --no-cache, --profile, the three output formats, and the error path. +# Runs in stateless mode (env-var connection; no config files). + +suite_sql() { + suite_banner "sql (execution, session vars, formats, errors)" + DCLI_STATELESS=1 + + # Envelope shape: query_id, exec_time_ms, rows_returned, columns[], rows[]. + expect_json "sql: simple select returns the full JSON envelope" \ + '.rows[0].one==1 and (.columns|index("one")) and (.query_id|type=="string") and (.rows_returned==1) and (.exec_time_ms>=0)' \ + sql "SELECT 1 AS one" + + # Mixed types: string stays a string, integer becomes a JSON number. + expect_json "sql: string and integer columns map correctly" \ + '.rows[0].g=="hello" and .rows[0].n==42' \ + sql "SELECT 'hello' AS g, 42 AS n" + + # -f <file>: read the query from a file. + printf 'SELECT 7 AS seven' > "$WORKDIR/seven.sql" + expect_json "sql: -f reads the query from a file" \ + '.rows[0].seven==7' \ + sql -f "$WORKDIR/seven.sql" + + # --set applies a session var before the query (integer var avoids normalization). + expect_json "sql: --set applies a session variable" \ + '.rows[0].p==5' \ + sql "SELECT @@parallel_pipeline_task_num AS p" --set "parallel_pipeline_task_num=5" + + # --set is repeatable. + expect_json "sql: multiple --set flags all apply" \ + '.rows[0].p==6 and .rows[0].w==2000' \ + sql "SELECT @@parallel_pipeline_task_num AS p, @@runtime_filter_wait_time_ms AS w" \ + --set "parallel_pipeline_task_num=6" --set "runtime_filter_wait_time_ms=2000" + + # --no-cache: just confirm it executes cleanly. + expect_json "sql: --no-cache executes" \ + '.rows_returned==1' \ + sql "SELECT 1 AS one" --no-cache + + # --profile: produces a non-empty query_id (reused by the profile suite). + expect_json "sql: --profile yields a query_id" \ + '(.query_id|type=="string") and ((.query_id|length)>0)' \ + sql "SELECT 2 AS two" --profile + + # Output formats: a TTY-style table and CSV both carry the column header. + expect_stdout_contains "sql: --format table renders the column" "one" \ + sql "SELECT 1 AS one" --format table + expect_stdout_contains "sql: --format csv renders the header" "one" \ + sql "SELECT 1 AS one" --format csv + + # Empty result set: zero rows, empty rows array, still exit 0. + expect_json "sql: empty result set is handled" \ + '.rows_returned==0 and (.rows==[])' \ + sql "SELECT table_name FROM information_schema.tables WHERE 1=0" + + # End-to-end against loaded data: row count matches what setup loaded. + expect_json "sql: count over loaded table matches the load" \ + '.rows[0].c=='"${ROWS:-2000}" \ + sql "SELECT COUNT(*) AS c FROM \`$CFG_DB\`.events" + + # Error path: a bad reference exits non-zero with an "SQL error:" message. + EXPECT_ERR_MATCH="SQL error" + expect_err "sql: invalid query exits non-zero" \ + sql "SELECT * FROM ${CFG_DB}.no_such_table_xyz" + EXPECT_ERR_MATCH="" +} diff --git a/tests/e2e/suite_tablet.sh b/tests/e2e/suite_tablet.sh new file mode 100644 index 0000000..bf4fc62 --- /dev/null +++ b/tests/e2e/suite_tablet.sh @@ -0,0 +1,53 @@ +# shellcheck shell=bash +# suite_tablet — bucket/tablet analysis against the seeded tables. +# Overview keys verified against src/commands/tablet/overview.rs; +# --detail keys against src/commands/tablet/detail.rs (note: without --partition, +# detail attributes every tablet to the first partition name — asserted as such). + +suite_tablet() { + suite_banner "tablet (model / bucket / health / detail)" + DCLI_STATELESS=1 + local events="$CFG_DB.events" + local dim="$CFG_DB.dim_users" + + # Overview: model, distribution, sort key, row count. + expect_json "tablet: events overview (model/bucket/sortkey/rows)" \ + '.model=="DUPLICATE" and .bucket_type=="HASH" and (.bucket_key|index("user_id")) and (.bucket_count==4) and (.partitions>=2) and (.total_rows>0) and (.sort_key|index("event_date"))' \ + tablet "$events" + + # Health summary is computed from SHOW DATA SKEW (object with a numeric skew). + expect_json "tablet: events health has a numeric tablet_skew" \ + '(.health|type=="object") and (.health.tablet_skew|type=="number")' \ + tablet "$events" + + # Column stats (ndv) require ANALYZE to have populated them — SKIP if absent. + DCLI_STATELESS=1 _run_dcli --format json tablet "$events" + if [ "$RC" -ne 0 ]; then + record_fail "tablet: events columns[].ndv populated" "exit=$RC; $ERR" + elif [ "$(jget '.columns|length')" = "0" ] || [ -z "$(jget '.columns|length')" ]; then + record_skip "tablet: events columns[].ndv populated" "no column stats collected (ANALYZE async/unsupported)" + elif printf '%s' "$OUT" | jq -e '.columns[0]|has("ndv")' >/dev/null 2>&1; then + record_pass "tablet: events columns[].ndv populated" + else + record_fail "tablet: events columns[].ndv populated" "columns present but missing ndv: $(jget '.columns[0]')" + fi + + # Model detection on the UNIQUE table. + expect_json "tablet: dim_users is detected as UNIQUE" \ + '.model=="UNIQUE"' \ + tablet "$dim" + + # --detail: per-tablet + per-backend distribution. 2 partitions x 4 buckets = 8 tablets. + expect_json "tablet: --detail lists tablets and backends" \ + '(.partitions|type=="array") and (.tablets|type=="array") and ((.tablets|length)>=8) and (.backends|type=="array")' \ + tablet "$events" --detail + + # --partition narrows to one partition's 4 tablets. + expect_json "tablet: --detail --partition filters to one partition" \ + '((.partitions|length)==1) and (.partitions[0].name=="p20240101") and ((.tablets|length)==4)' \ + tablet "$events" --detail --partition p20240101 + + # Negative: a missing table errors (SHOW CREATE TABLE fails). + expect_err "tablet: missing table exits non-zero" \ + tablet "$CFG_DB.no_such_table_xyz" +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
