97_negative_test.sh 8.13 KB
#!/usr/bin/env bash
#
# `97_verify_sample.sql` 的**反向用例**:证明那五段断言**真的会报红**。
#
# 为什么必须有这个文件(AGENTS.md §1.1 推论 1):
#   一份"跑出来是空的"的校验,**不能**证明数据是对的——它同时兼容"数据确实没问题"
#   和"这条断言根本不可能有输出"两种情形,而这两种情形在终端上长得一模一样。
#   本地第一次跑 97 时,§2a 报了上万行(5.7 的 `int(11)` vs 8.0 的 `int`)——
#   那次是**假红**,但它顺带证明了 §2a 不是空转的。这个脚本把这类证据固定下来:
#   对每一段各造一个**必然命中**的破坏,断言它确实报出来,而且**报的是该段该报的东西**。
#
# ⚠️ 断言为什么用"特征串"而不是"输出非空":
#   本脚本会故意把库弄坏,mysql 会往 stderr 打 `ERROR 1146 ... connector_info doesn't exist`,
#   而下面每条都 `2>&1` 一起收——若只判"非空",那个 ERROR 就能把五条用例全部"喂饱",
#   于是五条都通过,而真实情况是五段断言一个都没响。所以特征串必须**只可能来自该段的输出行**:
#     §1  → `zt_charge<TAB>connector_info`(ERROR 里是 `parking_cloud.connector_info`,不同)
#     §2a → `<TAB>bigint<TAB>int<TAB>`     (只有 §2a 印类型)
#     §2b → `zz_negative_test_col`          (破坏时新加的列名)
#     §3/§4 → `不该有数据` / `抽样没落地`    (只有这两段印这两个结论词)
#     §5  → `utf8mb3`                        (只有 §5 印排序规则)
#   特征串选得够"窄",ERROR 行就不可能冒充它——这是**选特征串**的责任,不是靠
#   另外加一条"输出里不许出现 ERROR"来兜。
#   (第一版真加了那条禁令,结果 A 用例永远失败:A 故意删表,§4 的 `COUNT(*)`
#    必然 `ERROR 1146`,而 §1 其实已经正确报出来了。那条禁令把"预期内的噪声"
#     和"这段没跑成"混为一谈——所以现在 ERROR 只作为提示打印,不作判据。)
#
# 用法(**只能对本地开发库跑**,它会改表;选 zt_charge 是因为重灌一次只要 2 秒):
#     MYSQL_BIN=/opt/homebrew/opt/mysql-client/bin/mysql \
#     DUMP_BIN=/opt/homebrew/opt/mysql-client/bin/mysqldump \
#     TARGET_HOST=127.0.0.1 TARGET_USER=root TARGET_PASSWORD= \
#       bash tests/97_negative_test.sh
#   依赖:目标库上已经有 SAMPLE=N 的抽样镜像(先跑 10_dump_legacy.sh dump && load)。

set -uo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MIG="$(cd "$HERE/.." && pwd)"
VERIFY="$MIG/97_verify_sample.sql"
DB="${DB:-zt_charge}"

[[ -f "$VERIFY" ]] || { echo "缺少 ${VERIFY}(先跑 tools/gen_migration_sql.py)" >&2; exit 1; }

MYSQL_BIN="${MYSQL_BIN:-mysql}"
command -v "$MYSQL_BIN" >/dev/null || { echo "缺少 mysql 客户端(${MYSQL_BIN})" >&2; exit 1; }
: "${TARGET_HOST:?请设置 TARGET_HOST}"
: "${TARGET_USER:?请设置 TARGET_USER}"
TARGET_DB="${TARGET_DB:-parking_cloud}"
TARGET_PORT="${TARGET_PORT:-3306}"
: "${TARGET_PASSWORD-}"
export MYSQL_PWD="$TARGET_PASSWORD"

mysql_() { "$MYSQL_BIN" --host="$TARGET_HOST" --port="$TARGET_PORT" \
                     --user="$TARGET_USER" --default-character-set=utf8mb4 "$@"; }

# 跑校验时加两个开关:
#   --force  —— 一段出错(比如 §4 撞到被删掉的表 `ERROR 1146`)**不要中止**,
#              否则 §5 永远不会执行,一份校验只报出前半截。
#   -N       —— 不输出列名。要的是"干净时**完全没有任何输出**",
#              有列名的话空结果集也会印一行表头,"空=通过"这个判读就不成立了。
mysql_verify() { mysql_ --force -N "$@"; }

pass=0; fail=0
ok()  { printf '  \033[32m✅\033[0m %s\n' "$*"; pass=$((pass+1)); }
bad() { printf '  \033[31m❌\033[0m %s\n' "$*"; fail=$((fail+1)); }

# 还原:重新灌这一个库(dump 里是 DROP TABLE IF EXISTS + CREATE + INSERT,幂等)
#
# ⚠️ 重灌**只能**还原"dump 里有的那几张表"——它永远**不会**删掉多出来的东西。
#    所以破坏手法要挑**重灌能完全吃掉的**:DROP TABLE / ALTER(都被
#    `DROP TABLE IF EXISTS` + `CREATE TABLE` 覆盖),**不要用 RENAME**——
#    改名会留下一张 dump 里没有的表(本文件的第一个版本就踩了,
#    在库里留了个 `connector_info_hidden`,而且失败路径上还没还原)。
restore() {
  ( cd "$MIG" && ONLY="$DB" SAMPLE="${SAMPLE:-500}" bash 10_dump_legacy.sh load >/dev/null 2>&1 )
}

# 基准:装载完整时 97 必须是**静默**的(否则后面的"报红"断言毫无意义)
baseline_must_be_silent() {
  local out
  out=$(mysql_verify < "$VERIFY" 2>&1) || true
  if [[ -z "$out" ]]; then ok "基准:抽样镜像完整时 97 静默(§1~§5 全 0 行)"
  else bad "基准就不干净,后面的断言没法判读:${out:0:300}"; return 1; fi
}

# $1=用例名  $2=该段输出里只可能由它产生的特征串  $3=破坏语句
#
# ⚠️ **失败路径也必须 restore**:不还原的话,后面每个用例都会因为上一条的破坏而连环失败,
#    于是看到的是"五条全错",真实原因却只有第一条——排查方向被带偏。
expect_red() {
  local name="$1" want="$2" ddl="$3" out err
  if ! err=$(mysql_ -e "$ddl" 2>&1); then
    bad "${name}:破坏语句没执行成功(错误原文:${err})"
    restore
    return
  fi
  out=$(mysql_verify < "$VERIFY" 2>&1) || true
  if [[ "$out" == *"$want"* ]]; then
    ok "$name —— 报红了,且报的是该段该报的东西"
    # ERROR 只提示、不判负:删表的用例里 §4 的 COUNT(*) 撞到它属预期,
    # 而 §1 已经正确报出来了。把"预期内的噪声"当成失败会造出假故障。
    if [[ "$out" == *ERROR* ]]; then
      printf '       ⚠️ 校验输出里有 ERROR(多段校验是 `--force` 跑的,一段出错不中止其余):%s\n' \
        "$(printf '%s' "$out" | grep -m1 'ERROR')"
    fi
  else
    bad "${name} —— **没报红**!这段断言是空转的。期望输出含「${want}」,实际:${out:0:300}"
  fi
  restore
}

TAB=$'\t'

echo "== 起点:先把这一个库重灌成已知good状态 =="
# ⚠️ 这一步不是多余的:本脚本上一次如果在半途死掉(比如被 Ctrl-C),
#    库里会留着上一条破坏语句的效果,于是"基准"一上来就是脏的,
#    后面的用例也跟着连环失败——看到的全是假故障。先自愈,再开始。
restore

echo "== 基准 =="
baseline_must_be_silent

echo "== A. §1 表都在(把一张表删掉)=="
expect_red "§1 目标库缺表" "zt_charge${TAB}connector_info" \
  "DROP TABLE \`$TARGET_DB\`.\`connector_info\`"

echo "== B. §2a 列类型(bigint → int,是真正改类型)=="
expect_red "§2a 列类型变了" "${TAB}bigint${TAB}int${TAB}" \
  "ALTER TABLE \`$TARGET_DB\`.\`connector_info\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT"

echo "== C. §2b 目标库多出来的列 =="
expect_red "§2b 多了一列" "zz_negative_test_col" \
  "ALTER TABLE \`$TARGET_DB\`.\`connector_info\` ADD COLUMN \`zz_negative_test_col\` int NULL"

echo "== D. §3/§4 —— 最值钱的一段,两个方向都要验 =="
# D1「不该有数据」:只搬结构的表(含密钥/口令)在抽样镜像里必须 0 行
expect_red "§3 只搬结构的表被灌进了数据" "不该有数据" \
  "INSERT INTO \`$TARGET_DB\`.\`operator_info\` (\`operator_id\`,\`operator_name\`,\`operator_tell\`) VALUES ('zz','zz','zz')"
# D2「抽样没落地」:这张表源库明明有数据,必须 >= 1 行
expect_red "§3 抽样表一行都没落地" "抽样没落地" \
  "DELETE FROM \`$TARGET_DB\`.\`connector_info\`"

echo "== E. §5 字符集(把一张表转回 utf8mb3)=="
expect_red "§5 有表不是 utf8mb4" "utf8mb3" \
  "ALTER TABLE \`$TARGET_DB\`.\`connector_info\` CONVERT TO CHARACTER SET utf8mb3"

echo
echo "== 收尾:还原后再跑一次基准 =="
restore
baseline_must_be_silent

echo
if [[ "$fail" -eq 0 ]]; then
  printf '\033[32m全部通过\033[0m ✅   (%d 项)—— 五段断言都能报红,不是空转\n' "$pass"
else
  printf '\033[31m%d 项失败\033[0m ❌\n' "$fail"
  exit 1
fi