Files
rond-with-you/internal/web/place_activity_test.go
T
tamakyi 86e420bd4e 新建地点的活动下拉可即时新增,地点行内编辑支持批量改该地点全部到访的活动
站长反馈「已有地点编辑和新建里只有类别没有活动能选」。实测澄清:新建地点卡一直有活动下拉,
只是形态与类别/照片页不一致(旁边常显一个新建框、下拉里没有「+ 新增活动…」);真正完全没有
活动字段的是地点列表行的「编辑」。

- 新建地点卡:活动下拉补「+ 新增活动…」哨兵,旁边的新建框改成选中哨兵才展开;
  删掉原来那对 #activity-add/#new-activity-name 监听——三处活动下拉现在共用同一套
  `visit-act-*` 委派处理器,重复监听会建出两条活动。
- 地点行内编辑:加「活动(改该地点全部到访)」下拉,默认「(不修改)」不预选当前值
  (否则每次改坐标保存都会重写历史到访的类型),并标出当前类型构成;选了真实活动时
  提交前 confirm 一次。
- 服务端(places 没有活动列,活动挂在到访上):editPlaceUpdate 解析 activity_id,空值=不修改,
  与地点行同一事务内 UPDATE visits 的 activity_id/is_home/is_work,flash 报改了几条;
  哨兵值/不存在的 id/别的数据集的活动一律拒。导出无需改动(ZVISIT.ZACTIVITY_ 本就是管理列)。
- 回归 internal/web/place_activity_test.go:批量改(含别的地点不受影响)、空值不修改、越权被拒、
  模板契约、以及导出包里那批到访的 ZACTIVITY_ 必须换成新活动的 rond 编号。
- 顺手:TestMovementCleanExport 建出 ROND_CLEAN_OUT 的父目录,别再把「目录不存在」报成导出失败。
2026-09-18 21:23:13 +08:00

225 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"database/sql"
"fmt"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/rond"
)
// TestPlaceUpdateBatchActivity 盯住「地点行内编辑里的活动」这条路:地点自己没有活动列
// (活动挂在到访上),所以那个下拉是「把该地点名下全部到访一起换类型」。
// 写错的两种后果都很安静:空值被当成「改成无活动」→ 历史到访的类型被清掉;
// 不校验归属 → 能把别的数据集的活动塞进来。
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run BatchActivity -v
func TestPlaceUpdateBatchActivity(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
// 挑到访最多的那个地点:批量的效果才看得出来
var pk int
var placeID int64
var n int
if err := pg.QueryRowContext(ctx, `SELECT p.id, p.src_pk, count(v.id) AS c FROM places p
JOIN visits v ON v.place_id = p.id
WHERE p.dataset_id=$1 GROUP BY p.id, p.src_pk ORDER BY c DESC, p.src_pk LIMIT 1`,
dsID).Scan(&placeID, &pk, &n); err != nil {
t.Fatalf("找不到带有访次的地点: %v", err)
}
if n < 2 {
t.Fatalf("这个数据集里没有多于一条到访的地点(最多 %d 条),验不出「批量」", n)
}
var name string
var lat, lon float64
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
FROM places WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&name, &lat, &lon); err != nil {
t.Fatalf("读地点失败: %v", err)
}
// 挑一个该地点还没用过的活动,否则「改了」和「没改」分不出来
var actID int64
var actName string
var actHome, actWork bool
if err := pg.QueryRowContext(ctx, `SELECT a.id, COALESCE(a.name,''), a.is_home, a.is_work FROM activities a
WHERE a.dataset_id=$1 AND NOT EXISTS (SELECT 1 FROM visits v
WHERE v.place_id=$2 AND v.activity_id=a.id)
ORDER BY a.id LIMIT 1`, dsID, placeID).Scan(&actID, &actName, &actHome, &actWork); err != nil {
t.Fatalf("找不到没被这个地点用过的活动: %v", err)
}
postRaw := func(activity string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {name},
"lat": {fmt.Sprintf("%.6f", lat)}, "lon": {fmt.Sprintf("%.6f", lon)},
"province": {""}, "city": {""}, "district": {""},
}
if activity != "" {
form.Set("activity_id", activity)
}
req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
f.srv.editPlaceUpdate(rec, req)
if rec.Code >= 500 {
t.Fatalf("处理失败: code=%d", rec.Code)
}
return rec
}
// is_home / is_work 是从活动上抄到到访的冗余标记,换活动时要跟着一起走
matched := func() int {
t.Helper()
var c int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
WHERE dataset_id=$1 AND place_id=$2 AND activity_id=$3 AND is_home=$4 AND is_work=$5`,
dsID, placeID, actID, actHome, actWork).Scan(&c); err != nil {
t.Fatalf("统计失败: %v", err)
}
return c
}
var othersBefore int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersBefore); err != nil {
t.Fatal(err)
}
// 1) 选中一个活动:该地点名下全部到访一起换过去
rec := postRaw(fmt.Sprint(actID))
loc := rec.Header().Get("Location")
if msg, _ := url.QueryUnescape(loc); !strings.Contains(msg, "ok=") || !strings.Contains(msg, "条到访") {
t.Fatalf("提示里要说清改了几条到访,实际 %q", loc)
}
if got := matched(); got != n {
t.Fatalf("该地点有 %d 条到访,只有 %d 条换成了「%s」(含 is_home/is_work)", n, got, actName)
}
var othersAfter int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersAfter); err != nil {
t.Fatal(err)
}
if othersAfter != othersBefore {
t.Fatalf("改一个地点的活动,别的地点的到访也变了:%d → %d", othersBefore, othersAfter)
}
// 2) 「(不修改)」:行内表单每次都会提交这一项,空值必须一个到访都不动
postRaw("")
if got := matched(); got != n {
t.Fatalf("没选活动却动了到访:只剩 %d/%d 条还是「%s」", got, n, actName)
}
// 3) 哨兵值「+ 新增活动…」:前端有守卫,服务端也要拦(当 id 会解析成「无活动」)
for _, raw := range []string{"__new__", "999999"} {
rec = postRaw(raw)
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
t.Fatalf("提交 activity_id=%q 应当被拒绝,实际 %q", raw, l)
}
if got := matched(); got != n {
t.Fatalf("提交 activity_id=%q 之后到访被改了:只剩 %d/%d 条还是「%s」", raw, got, n, actName)
}
}
// 3b) 别的数据集的活动:越权也得拒(resolveActivity 带 dataset_id 条件就是为了这个)
var foreign int64
if err := pg.QueryRowContext(ctx, `SELECT id FROM activities WHERE dataset_id<>$1 ORDER BY id LIMIT 1`,
dsID).Scan(&foreign); err == nil {
rec = postRaw(fmt.Sprint(foreign))
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
t.Fatalf("提交别的数据集的活动 #%d 应当被拒绝,实际 %q", foreign, l)
}
if got := matched(); got != n {
t.Fatalf("越权提交之后到访被改了:只剩 %d/%d 条还是「%s」", got, n, actName)
}
} else if err != sql.ErrNoRows {
t.Fatalf("查别的数据集的活动失败: %v", err)
}
// 4) 模板:行内表单必须带上活动下拉 + 新建框,且下拉里三样齐全
// (JS 靠 .activity-id-inline / .visit-act-new / .visit-act-add 三个类名找同伴元素)
req := httptest.NewRequest("GET", "/admin/edit", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
f.srv.editPage(w, req)
if w.Code != 200 {
t.Fatalf("编辑页渲染失败: code=%d", w.Code)
}
body := w.Body.String()
for _, want := range []string{
`action="/admin/edit/place/update"`, `select name="activity_id" class="activity-id-inline"`,
`class="f visit-act-new"`, `class="btn visit-act-add"`, `(不修改)`, `+ 新增活动…`,
} {
if !strings.Contains(body, want) {
t.Fatalf("编辑页里找不到 %q:模板与 JS 的契约断了", want)
}
}
// 这一行的提示要落在它自己身上:地点按到访数倒序排,246 条的那个在第 1 页
i := strings.Index(body, fmt.Sprintf(`id="place-row-%d"`, pk))
if i < 0 {
t.Fatalf("编辑页第 1 页找不到 #%d 这一行", pk)
}
row := body[i:]
if j := strings.Index(row, `<tr id="place-row-`); j > 0 {
row = row[:j]
}
if !strings.Contains(row, "该地点到访当前类型:") ||
!strings.Contains(row, actName+" "+fmt.Sprint(n)) {
t.Fatalf("这一行的「当前类型」提示不对(期望 %s %d):%s", actName, n, row[:min(len(row), 320)])
}
// 5) 导出链路:站点改的是 visits.activity_id,导出的包里对应到访必须换成新活动的 **src_pk**
// —— 站点内部 id 与 rond 编号是两套号,写错就会「网页上改了、rond 里还是旧活动」。
var actSrc int
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM activities WHERE id=$1`, actID).Scan(&actSrc); err != nil {
t.Fatalf("读活动编号失败: %v", err)
}
visitRows, err := pg.QueryContext(ctx, `SELECT src_pk FROM visits WHERE dataset_id=$1 AND place_id=$2`, dsID, placeID)
if err != nil {
t.Fatalf("读到访编号失败: %v", err)
}
var pks []int
for visitRows.Next() {
var v int
if err := visitRows.Scan(&v); err != nil {
visitRows.Close()
t.Fatalf("扫到访编号失败: %v", err)
}
pks = append(pks, v)
}
visitRows.Close()
blob := f.export(t)
out := filepath.Join(t.TempDir(), "activity-export.rondbackup")
if err := os.WriteFile(out, blob, 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
ph := make([]string, len(pks))
args := make([]any, 0, len(pks)+1)
for i, v := range pks {
ph[i] = "?"
args = append(args, v)
}
args = append(args, actSrc)
var inPackage, converted int
q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` +
strings.Join(ph, ",") + `)`
if err := edb.QueryRow(q, args...).Scan(&inPackage, &converted); err != nil {
t.Fatalf("读导出包失败: %v", err)
}
if inPackage != len(pks) {
t.Fatalf("导出包里只有 %d/%d 条到访", inPackage, len(pks))
}
if converted != len(pks) {
t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID)
}
}