站长反馈「已有地点编辑和新建里只有类别没有活动能选」。实测澄清:新建地点卡一直有活动下拉, 只是形态与类别/照片页不一致(旁边常显一个新建框、下拉里没有「+ 新增活动…」);真正完全没有 活动字段的是地点列表行的「编辑」。 - 新建地点卡:活动下拉补「+ 新增活动…」哨兵,旁边的新建框改成选中哨兵才展开; 删掉原来那对 #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 的父目录,别再把「目录不存在」报成导出失败。
225 lines
8.8 KiB
Go
225 lines
8.8 KiB
Go
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)
|
||
}
|
||
}
|