package web import ( "database/sql" "fmt" "net/http/httptest" "net/url" "os" "path/filepath" "sort" "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, ` 1 { dup = append(dup, v) } if !have[v] { missing = append(missing, v) } } sort.Ints(missing) sort.Ints(dup) return missing, dup, total }