一条采购链路,需要几种不同的证据

采购申请包含两支纸和三支笔,服务端核价后金额为 13.75;提交、审批、建单,重复建单仍返回原订单。这个业务结果可以在纯 Java 对象中推演,却不能用模拟仓储证明数据库真的拒绝重复订单,也不能用单独的 JDBC 测试证明 WAR 在容器中拿到了预期数据源。测试分层不是为了多跑几套测试,而是为了在失败时知道哪个边界已经被覆盖。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

入口是 examples/javaee-enterprise/README.md。当前累计工程有 domain/ 的规则测试、PostgreSQL 16.15 上的显式 SQL、Open Liberty 26.0.0.5 的受管 DataSource 和隔离教学接口;它不是完整认证、消息或发布工程。JDK 21、Jakarta EE 11 Platform、pgJDBC 42.7.7 与 Maven Wrapper 3.9.9 是本地基线。先核对服务实际连接的 javaee_lab,不要复用 JPA 的 jpa_lab。容器 ZIP 的本机运行结果不是兼容性认证声明。

把断言放在负责该行为的边界

ProcurementRulesTest 可快速检查状态转换与金额规则;DAO 的 SQL 和唯一约束需要真正的 PostgreSQL 才有意义。数据库集成测试还应以两个连接观察提交后终态:一个事务里看到的行,不能代替另一连接所见的已提交行。容器测试需要部署实际 WAR,经 HTTP 进入 CDI/受管事务和 DataSource,再从独立数据库连接核对申请、订单与租户列。对外 REST 契约至少固定状态码和响应结构;端到端测试还要跨权限、消息与恢复路径,目前后者尚无实现,不能把教学 HTTP 接口当作已授权入口。

现有调用链具体是 RestApplication 把 REST 根路径设为 /api,LabRequestsResource 接受 X-Lab-Tenant 与商品数量,注入 ProcurementUseCases。后者在 @Transactional 方法内通过 FixedPriceCatalog 对 paper 和 pen 分别取 3.50、2.25;JdbcRequestStore 用 @Resource(lookup = "jdbc/Procurement") 取得受管 javax.sql.DataSource,以 INSERT ... RETURNING id 写申请、批量插入明细。这些入口在同一 WAR 内,但 new ProcurementUseCases(...) 的对象级单测不能据此证明容器的注入、资源解析或拦截发生过。server.xml 将驱动目录和 javaee_lab 绑定到 jdbc/Procurement;05-datasource.sh 的 connected 只能证明此时读到了预期库名,不替代写事务测试。

状态迁移分成 Java 与数据库两道门。RequestState 只准 DRAFT → SUBMITTED → APPROVED → ORDERED,SUBMITTED 还可进入 REJECTED。DAO 的 UPDATE 另外匹配旧状态与版本;并发调用读到同一个版本时,只有更新到一行的调用才会走通,零行被转为业务冲突。建单先执行 INSERT ... ON CONFLICT(request_id) DO NOTHING RETURNING id,再执行条件状态更新;purchase_order.request_id 的 UNIQUE 限制同一申请在库中至多一张订单。它没有自动验证订单的 tenant_id 与申请租户相同,当前服务传同一个租户参数并通过按申请和租户读取缩小风险;若存在其他直写入口,还需额外检查。

1
2
3
-- 核心并发谓词来自 JdbcRequestStore.transition;这是源码节选,不是本篇运行的 SQL 输出
UPDATE purchase_request SET status=?, version=version+1
WHERE id=? AND tenant_id=? AND status=? AND version=?;

测试层次由失败判据决定。一个 RequestConflictException 映射成 HTTP 409,只表示请求在该入口被拒,不能从状态码判断后续是否有其他事务提交;找不到指定租户的申请映射成 404,只能证明当前 SQL 的租户谓词按传入值起效。再用数据库独立连接检查 purchase_request.status、version、purchase_line 金额和 purchase_order 计数,才能把 HTTP 与最终持久状态连起来。其余客户端可直接改写 X-Lab-Tenant,因此不能拿此 404 作认证矩阵的通过证据。

Schema 也属于测试输入的一部分:新库建表成功不证明旧库可升级。JPA 扩展中的 Schema 迁移与集成测试 只记录了 RESOURCE_LOCAL 新建库的部分通过路径,旧库演进和容器集成仍未运行。相同业务规则在 采购审批综合应用 中仅有单次顺序重试及跨租户用例的本地证据;本篇的 JDBC/容器结果不能转借给 ORM,反之亦然。

测试顺序按破坏半径递增:先跑规则,再在隔离库初始化 schema,启动隔离容器,最后运行 HTTP 和故障用例。工程入口文档列有 WAR 复制位置、服务器配置、驱动路径及本地环境变量;以下命令在其独立库与容器已经按文档启动后从仓库根目录执行。JAVAEE_DEMO_MODE=true 必须在启动服务器进程前设置,且仅允许绑定 127.0.0.1;事后在客户端设置不能打开入口。脚本自身检查输入、响应以及数据库终态,退出零只是它实际断言的范围。

1
2
3
4
5
6
7
8
cd examples/javaee-enterprise
../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" clean verify
export JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab
printf 'Local lab database password: '; read -rs JAVAEE_LAB_PASSWORD; printf '\n'
export JAVAEE_LAB_PASSWORD
bash scenarios/00-health.sh
bash scenarios/05-datasource.sh
bash scenarios/09-lab-procurement.sh

正常脚本会核对提前建单的 409、跨演示租户访问的 404、审批后的 200、顺序重试返回相同订单 ID,最终从 PostgreSQL 读到 ORDERED|13.75|1。其中 X-Lab-Tenant 是客户端可伪造的教学字段,404 不能证明真实身份鉴别或全入口租户隔离。业务脚本是在已部署工程上的有界场景,不能用一个 200 代替发布后完整采购验收。

还可以从该脚本的原始输出取出它刚生成的申请,独立查询版本、明细金额及订单数。以下命令在同一终端继续执行,额外创建一条随机租户申请,不修改既有业务记录;psql -v 对输入值做 SQL 字符串引用,不要把不可信租户值直接拼到 SQL 里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
set -e
set -o pipefail
run_output=$(bash scenarios/09-lab-procurement.sh)
printf '%s\n' "$run_output"
lab_tenant=$(printf '%s\n' "$run_output" | sed -n 's/^tenant=\([^ ]*\) request_id=.*/\1/p')
request_id=$(printf '%s\n' "$run_output" | sed -n 's/^tenant=[^ ]* request_id=\([0-9]*\).*/\1/p')
test -n "$lab_tenant" && test -n "$request_id"
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-v ON_ERROR_STOP=1 -v tenant="$lab_tenant" -v request_id="$request_id" <<'SQL'
SELECT r.id, r.version, r.status, r.total,
(SELECT sum(unit_price * quantity) FROM purchase_line WHERE request_id=r.id) AS lines_total,
(SELECT count(*) FROM purchase_order WHERE request_id=r.id) AS order_count
FROM purchase_request r WHERE r.id=:'request_id'::bigint AND r.tenant_id=:'tenant';
SQL

在没有其他写入同一申请的条件下,预计 version=3,status=ORDERED,总额与明细合计均为 13.75,订单数为 1。这个 version 是从源码三次条件更新推导出的待检查值,不是本篇新增的一次实测结果。若只见 purchase_order 却还是 APPROVED,需先调查订单写入和状态更新是否真的参加同一事务;不能以“订单数是 1”掩盖状态不一致。若金额不同,先核对是否运行了仓库当前 FixedPriceCatalog、是否连到了正确库,而非立即改掉断言。

失败路径使用已有的注入入口:写入申请和明细后故意抛异常,并用另一个数据库连接检查该租户提交行数为零。

1
2
# 同一隔离服务器、同一组环境变量;预期脚本退出 0、注入请求 HTTP 500
bash scenarios/18-lab-rollback.sh

脚本还记录两个序列值的前后变化:序列消耗不随事务回滚,不是“产生孤儿申请”的证据。若 HTTP 不是 500、数据库仍有申请行、脚本退出非零,就要保留 stdout、配置、WAR/源码摘要及数据库查询,而不是只重跑到绿色。归档的 writing-plans/javaee-enterprise/verification/20261004T063400Z-pg16-jta-rollback/RUN.md 含一次隔离运行记录;本章没有重新运行这些命令。归档指向当时的源码清单和场景输出,不能自动代表当前工作树。

恢复检查要与失败检查分开。18-lab-rollback.sh 对每轮随机租户已经执行一次独立连接查零行;紧接着再次运行 09-lab-procurement.sh,若新租户的订单能正常创建,只说明故障结束后该服务在这条新路径可用。它不证明原请求“可以安全重投”:上一个故障注入发生在事务提交之前,而现实中的响应丢失可能发生在提交之后。对于结果未知的正常请求,应凭租户与申请 ID 在新连接中查状态和订单,再决定返回旧结果、重试还是人工介入。

若要隔离测试数据库本身的约束,而非重复请求 HTTP,可以在可丢弃的 javaee_lab 执行不触及生产数据的负例。valid_request_state 对非法字符串应报错,ON_ERROR_STOP 确保调用方拿到非零退出;这个 SQL 不演示完整状态转换,不能把约束错误当成服务级 409:

1
JAVAEE_LAB_USER=javaee_lab bash scenarios/35-check-constraint.sh

脚本要求预期 SQLSTATE 23514、目标约束 valid_request_state,并以唯一探针租户独立查询残留行数 0;权限不足、连接失败或语法错误都不能算 CHECK 测试通过。scenarios/test-failure-gates.sh 还用 42501 权限错误替身验证拒绝误判;实际 CHECK 仍须在可丢弃库运行。CHECK 只限制合法枚举集合,仍需业务层决定从 DRAFT 能否跳到 ORDERED;测试应让每一道不变量落到其负责的边界。

下一轮故障合同

每次从可丢弃库初始化,记录 schema 版本和迁移退出码;用独立连接验证订单唯一键、审批冲突的最终行集合;在有认证实现之后对每一个查询和写入口覆盖越权访问;加入数据库不可用、消息重投和服务重启用例。对于一次 HTTP 失败,保留“请求是否提交、客户端是否收到响应、后续重读得到什么”三列,而非把所有 5xx 当回滚。双事务争单、旧库升级、认证及 broker 故障在本工程均为 NOT_RUN,不能从上方脚本推断结果。对应可复跑范围见 测试合同。

新库测试先确定确实在测试正确结构。当前 001-initial.sql 建立 purchase_request、purchase_line、purchase_order;申请含 version,订单的 request_id 为唯一外键,另有 (tenant_id,status,id) 索引。数据库结构可用只读检查复核,而不是假定脚本零退出就建对了所有对象:

1
2
3
4
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-v ON_ERROR_STOP=1 -c "SELECT table_name,column_name,data_type FROM information_schema.columns WHERE table_schema='public' AND table_name IN ('purchase_request','purchase_line','purchase_order') ORDER BY table_name,ordinal_position"
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-v ON_ERROR_STOP=1 -c "SELECT conrelid::regclass AS table_name,contype,pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid IN ('purchase_request'::regclass,'purchase_line'::regclass,'purchase_order'::regclass) ORDER BY conrelid::regclass::text,conname"

这些命令只读 public 中当前实验表;若表不存在,第二条会报错且 ON_ERROR_STOP 使流程失败。若旧库已有相同表名但少列,直接再执行 001-initial.sql 会失败,并不会回填版本列;要证明升级必须用可追溯的旧版 fixture 和新的增量迁移,在两套数据量不同的旧库副本中分别检查结构、存量记录和旧/新应用读写。单凭新库建表记录无法把旧库迁移写成 PASS。

并发测试必须控制两个事务在“都读到 APPROVED”之后才继续。记录各自的旧 version、订单插入返回值、条件更新行数、提交或回滚结果,并在两者都结束后以第三条连接按申请 ID 查询状态、版本、订单数。唯一键能挡住持久化的第二单,不能预言两条 HTTP 请求一定分别得到 200 和 409:ON CONFLICT DO NOTHING 的读回、条件更新和事务竞争还取决于时序;当前脚本从未驱动这个交错。对于测试中未捕捉的异常,要把服务器异常链和两次事务输出一起保留,不能只保留最终 count=1。

控制时序时要区分“复用一个申请做并发实验”和“为两个并发请求分别创建申请”。前者检验同一 request_id 的唯一订单及条件更新,后者检验不同申请是否被租户、金额或采购人上下文错误地混在一起。每个参与方先保存自己读到的 (status,version),在同一道同步门释放写入,记录 HTTP 完成、SQL 报错和事务结束时刻;最后再从与两位参与方都不同的第三连接读回。若两位参与方都收到错误而数据库却有订单,也要继续查提交确认和错误映射,不能强行写成“必有一个胜者”。这组可控并发入口在当前 scenarios/ 中尚不存在,验收表只能留待补测。

类似地,数据库拒绝非法 status 的 CHECK 与服务拒绝非法状态迁移的 409 是不同测试:CHECK 允许 ORDERED 这个合法字符串,但它不会单独检查申请此前是否得到批准;Java 的 RequestState 限制转移,但若另有 DBA 脚本或维护接口直接更新表,仍可绕过它。设计故障用例时先说清针对哪一层,再选 SQL 负例或 HTTP 负例;只用最容易跑通的一层盖住另一层,会把跨层数据修复风险留给正式环境。

失败复盘的最小记录还应区分执行时点:连接获取前失败、首条 INSERT 后失败、提交前失败、提交成功但客户端断连。现成的 /abort 仅覆盖其中“写 SQL 后、事务提交前抛未捕获异常”这一格;既然该方法在 @Transactional 内调用 draft,不能用这格结果推断数据库断连后的恢复、超时回滚或 XA。保存版本、配置和已部署 WAR 哈希是为了把“在某台机器上看到 500”转换成能定位同一次代码和环境的证据。

归档时还要保存故障复测的先后次序:清空或创建独立库、执行初始化 SQL、部署哪份 WAR、打开演示模式、发起请求、从另一连接查询、关闭注入,再运行正常场景。只存“最后正常了”会丢失故障在恢复前究竟留下什么;只存失败日志又无法证明服务恢复后新请求可以处理。原始服务器日志往往含环境相关路径、连接异常和租户值,公开归档前要去掉口令和会话凭据,但不能把关键信息删成无法复核版本与事务边界的截图。

两道练习

练习一: 09-lab-procurement.sh 返回同一个订单 ID 两次,是否证明两个客户端同时下单至多一单?如何补证?

解: 脚本的两次调用是顺序执行。另用两个独立连接同时读取同一已审批申请,控制提交顺序,检查第二次请求结果及提交后的 purchase_order 行数和唯一约束;即使客户端收到超时,也必须用新连接读回业务结果。不能只检查 Java 内存中的订单 ID。

练习二: 一次注入请求返回 500 且申请序列向前移动。如何判断事务是否回滚?

解: 用另一连接按该测试租户查询已提交的申请、明细和订单;序列非事务性,不以序列增长否定回滚。若已提交行存在,继续追查事务边界和注入时机;若为零,只能证明这一路径,没有覆盖响应丢失后的提交未知情形。

边界与版本出处

本地已归档的只是特定 WAR、脚本和数据库状态下的正向采购及受管事务故障用例;未验证完整契约测试、JPA 容器扩展、认证、跨请求并发或全部故障恢复。规则测试不能替代 SQL 约束,健康检查不能替代业务断言,容器单机实验不能替代第二运行时兼容性测试。