package web import ( "context" "database/sql" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // TestEditVisitUpdateAtomicOnClampFailure 证明「改到访」整组写入是一个事务: // 其中夹行程那步失败时,前面已经写进去的到访时间必须一并回滚。 // // 手法是给 movements 挂一个必然报错的 BEFORE UPDATE 触发器——夹行程(clampVisitMovements) // 就是靠 UPDATE movements 落地的,触发器一炸它就返回错误。 // 回滚生效 ⇒ 到访时间保持原值;回滚失效 ⇒ 到访时间已被改掉(旧代码各写各的、没有事务, // 到访那条 UPDATE 早就提交了)。 func TestEditVisitUpdateAtomicOnClampFailure(t *testing.T) { f := newMovementFixture(t) ctx, pg, dsID := f.ctx, f.pg, f.dsID // 挑一条被行程引用的到访,连同它那条行程的开始时间 var visitSrc, movePK int var mvSt, mvEn time.Time if err := pg.QueryRowContext(ctx, `SELECT m.from_visit_src, m.src_pk, m.started_at, m.ended_at FROM movements m WHERE m.dataset_id=$1 AND m.from_visit_src IS NOT NULL AND m.started_at IS NOT NULL AND m.ended_at IS NOT NULL ORDER BY m.src_pk LIMIT 1`, dsID).Scan(&visitSrc, &movePK, &mvSt, &mvEn); err != nil { t.Fatalf("挑不出「被行程引用的到访」: %v", err) } var origArrival time.Time var origDeparture sql.NullTime if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&origArrival, &origDeparture); err != nil { t.Fatalf("读取到访失败: %v", err) } // 把离开时间推到行程开始之后,夹行程就必然要动手改这条行程 newArrival := mvSt.Add(-2 * time.Hour).Truncate(time.Minute) newDeparture := mvSt.Add(time.Hour).Truncate(time.Minute) installMovementUpdateFailTrigger(t, ctx, pg) form := url.Values{ "src_pk": {fmt.Sprint(visitSrc)}, "arrival": {newArrival.Local().Format("2006-01-02T15:04")}, "departure": {newDeparture.Local().Format("2006-01-02T15:04")}, } r := httptest.NewRequest("POST", "/admin/edit/visit/update", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() f.srv.editVisitUpdate(w, r.WithContext(f.ctx)) if w.Code != http.StatusInternalServerError { t.Fatalf("夹行程失败应报 500,实际 code=%d body=%s", w.Code, w.Body.String()) } var gotArrival time.Time var gotDeparture sql.NullTime if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&gotArrival, &gotDeparture); err != nil { t.Fatalf("回读到访失败: %v", err) } same := gotArrival.Equal(origArrival) && gotDeparture.Valid == origDeparture.Valid && (!gotDeparture.Valid || gotDeparture.Time.Equal(origDeparture.Time)) if !same { t.Errorf("夹行程失败后到访 #%d 的时间被改掉了:%v/%v → %v/%v(整组写入应当回滚)", visitSrc, origArrival, origDeparture, gotArrival, gotDeparture) } // 行程那一端也不能被改 var gotSt, gotEn time.Time if err := pg.QueryRowContext(ctx, `SELECT started_at, ended_at FROM movements WHERE dataset_id=$1 AND src_pk=$2`, dsID, movePK).Scan(&gotSt, &gotEn); err != nil { t.Fatalf("回读行程失败: %v", err) } if !gotSt.Equal(mvSt) || !gotEn.Equal(mvEn) { t.Errorf("行程 #%d 的时间被改掉了:%v/%v → %v/%v", movePK, mvSt, mvEn, gotSt, gotEn) } t.Logf("夹行程失败后到访 #%d(%v ~ %v)与行程 #%d 均保持原值 ✓", visitSrc, origArrival, gotDeparture, movePK) } // installMovementUpdateFailTrigger 给 movements 挂一个必然报错的 BEFORE UPDATE 触发器。 // 名字带 e2e_ 前缀以免撞上别的对象;用完即弃(t.Cleanup 里 drop,且在连接关闭之前跑)。 func installMovementUpdateFailTrigger(t *testing.T, ctx context.Context, pg *sql.DB) { t.Helper() if _, err := pg.ExecContext(ctx, `CREATE OR REPLACE FUNCTION e2e_fail_movement_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'e2e: 事务原子性用例故意失败'; END $$`); err != nil { t.Fatalf("建触发器函数失败: %v", err) } if _, err := pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`); err != nil { t.Fatalf("清理旧触发器失败: %v", err) } if _, err := pg.ExecContext(ctx, `CREATE TRIGGER e2e_fail_movement_update BEFORE UPDATE ON movements FOR EACH ROW EXECUTE FUNCTION e2e_fail_movement_update()`); err != nil { t.Fatalf("建触发器失败: %v", err) } t.Cleanup(func() { pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`) pg.ExecContext(ctx, `DROP FUNCTION IF EXISTS e2e_fail_movement_update()`) }) }