Files
ARTEX/server/task_categories_test.go
Autumn-27 fea78b2d6f feat(tasks): 任务列表支持批量修改分类
勾选多个任务后可一次性移动到目标分类,或选「未分类」把它们移出当前分类。

- db: 新增 SetTasksCategory,在单个事务里校验分类存在性并 UPDATE ... WHERE
  id=ANY(...),返回真正被更新的 id 和刷新后的分类(含 task_count)。分类检查
  放在事务内,避免并发删除把 UPDATE 变成外键冲突。单批上限 100。
- server: 新增 POST /api/tasks/category/batch。写入原子,响应里的逐任务失败
  只可能是勾选后又被删掉的任务;顺带把 category_id 的解析(必填但可为 null)
  抽成 parseCategoryIDField 与单任务接口共用。
- server: 把批量 id 的 trim/归一/去重逻辑从 controlTasksBatch 抽成
  normalizeBatchTaskIDs,两个批量端点共用同一套重复判定。
- manager: SetTasksCategory 在 taskStateMu 内完成 DB 写入与运行中任务 DTO
  刷新,防止与单任务更新交错后留下过期的 category_name。
- web: 选中工具栏新增「修改分类」对话框(需确认目标,避免误点批量改动);
  「未分类」哨兵值统一提取为 UNCATEGORIZED_VALUE。

验证: go build / vet / gofmt、db 与 server 新增测试连本地 PG 实跑通过、
tsc --noEmit、biome check、next build 均通过。
2026-08-24 09:16:44 -04:00

85 lines
2.9 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestTaskCategoryBatchRoute pins the contract of the batch move endpoint: it is
// registered ahead of the /api/tasks/{id}/... patterns, it validates the request
// before touching the database, and it reports unknown ids per task instead of
// failing the whole batch.
func TestTaskCategoryBatchRoute(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
defer m.Close()
td := t.TempDir()
s := New(context.Background(), m, td, td, td)
h := s.Handler()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/tasks/category/batch", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// category_id is required, and omitting it must not be read as "uncategorize".
if rec := post(`{"task_ids":["1"]}`); rec.Code != http.StatusBadRequest ||
!strings.Contains(rec.Body.String(), "category_id is required") {
t.Fatalf("missing category_id: status=%d body=%s", rec.Code, rec.Body.String())
}
// An empty selection is rejected before any database work.
if rec := post(`{"task_ids":[],"category_id":null}`); rec.Code != http.StatusBadRequest ||
!strings.Contains(rec.Body.String(), "task_ids 数量必须为") {
t.Fatalf("empty task_ids: status=%d body=%s", rec.Code, rec.Body.String())
}
// Unknown and malformed ids come back as per-task failures on a 200, so the
// caller can tell exactly which selections went stale.
rec := post(`{"task_ids":["999999999","abc","999999999"],"category_id":null}`)
if rec.Code != http.StatusOK {
t.Fatalf("batch status=%d body=%s", rec.Code, rec.Body.String())
}
var response struct {
Items []struct {
ID string `json:"id"`
OK bool `json:"ok"`
Error string `json:"error"`
} `json:"items"`
Category *struct {
ID int64 `json:"id"`
} `json:"category"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
}
// The duplicate id is collapsed, so two entries remain.
if len(response.Items) != 2 {
t.Fatalf("items=%+v, want 2 after de-duplication", response.Items)
}
if response.Items[0].ID != "999999999" || response.Items[0].OK || response.Items[0].Error != "task not found" {
t.Fatalf("unknown id entry=%+v", response.Items[0])
}
if response.Items[1].ID != "abc" || response.Items[1].OK || response.Items[1].Error != "bad task id" {
t.Fatalf("malformed id entry=%+v", response.Items[1])
}
if response.Category != nil {
t.Fatalf("category=%+v, want null when clearing", response.Category)
}
}