Files
homeagent-sdk/example/vikunja/plugin_test.go
JianFeeeee 4482235312 fix(vikunja): body 里的 ID 必须是 JSON 数字(建任务/指派在 v2 下必定 422)
线上真调用暴露:vikunja_task_create 把 project_id 当字符串发出 →
422 validation failed: expected integer at body.project_id。同类的还有指派,
而且 v2 的 assignees **根本不接受 username 字段**(422 unexpected property),
两个分支都是坏的。单测没盖到,因为从未发过真实请求体。

实测(vikunja v2.6.0,2026-09-12):
  {"project_id":"1"}    → 422 expected integer
  {"user_id":"1"}       → 422 expected integer
  {"username":"jianf"}  → 422 unexpected property
  {"user_id":1}          → 201 ✓
  {"label_id":1}         → 201 ✓(插件本来就 Atoi,无需改)

修法:
- taskBody:project_id 走 parseID(数字)
- 新增 resolveUserID:用户名 → 数字 id,查 GET /users?q=(v1 用 ?s=);
  **只认精确匹配**,不做「只有一条就用它」的模糊兜底 —— 指派是写别人任务的动作,猜错人更贵
- assigneeBody:v2 只发 {"user_id":N},不再带 username
- task_assignees remove:路径也用解析后的数字 id

新增 5 项回归测试钉住请求体形状(数字 project_id / user_id、无 username 字段、
数字 ID 不查用户表、移除走数字路径、未知用户给可读错误)。
版本 1.0.0 → 1.0.1。线上复验:create(project_id=1, assignees=jianf) 不再报错、
add→list 显示 jianf、标签 add/remove 正常,测试数据已清理(任务/标签残留 0)。
2026-09-12 23:43:08 +08:00

524 lines
18 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 main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// newTestPlugin 构造一个不依赖 sdk 的插件实例,指向 httptest 服务。
// ensure() 在 sdk==nil 时会保留已设置的字段,因此可以这样直接测处理器。
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
p := &Plugin{
name: "vikunja",
baseURL: srv.URL,
token: "tk_test",
apiVer: "v2",
maxItems: 5,
compact: true,
http: srv.Client(),
}
return p, srv
}
func mustJSON(t *testing.T, v interface{}) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
// 1) 列表v2 用 q= 搜索,且 filter 会带上默认 done 条件
func TestTasksListV2(t *testing.T) {
var gotQuery string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
if r.Header.Get("Authorization") != "Bearer tk_test" {
t.Errorf("缺少 Bearer 头: %q", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":1,"title":"写周报","done":false,"project_id":3,"due_date":"2026-09-13T10:00:00Z","labels":[{"title":"工作"}],"assignees":[{"username":"jianf"}]}]`))
})
res, err := p.handleTasksList(map[string]interface{}{"search": "周报", "limit": float64(5)})
if err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(gotQuery, "q=%E5%91%A8%E6%8A%A5") {
t.Errorf("v2 应使用 q= 搜索,实际 query=%s", gotQuery)
}
if strings.Contains(gotQuery, "s=") {
t.Errorf("v2 不应使用 s=,实际 query=%s", gotQuery)
}
if !strings.Contains(gotQuery, "per_page=5") {
t.Errorf("per_page 未生效: %s", gotQuery)
}
if !strings.Contains(gotQuery, "filter=done+%3D+false") && !strings.Contains(gotQuery, "filter=done%20%3D%20false") {
t.Errorf("默认应过滤未完成,实际 filter 片段: %s", gotQuery)
}
m, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("结果应为 map实际 %T", res)
}
if m["count"].(int) != 1 {
t.Errorf("count 应为 1实际 %v", m["count"])
}
tasks := m["tasks"].([]interface{})
tk := tasks[0].(map[string]interface{})
if _, ok := tk["labels"].([]string); !ok {
t.Errorf("标签应被投影成名称数组,实际 %T", tk["labels"])
}
if _, ok := tk["description"]; ok {
t.Errorf("精简输出不应出现 description")
}
}
// 2) 列表v1 用 s= 搜索
func TestTasksListV1SearchParam(t *testing.T) {
var gotQuery string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(`[]`))
})
p.apiVer = "v1"
if _, err := p.handleTasksList(map[string]interface{}{"search": "abc"}); err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(gotQuery, "s=abc") {
t.Errorf("v1 应使用 s= 搜索,实际 %s", gotQuery)
}
if strings.Contains(gotQuery, "q=") {
t.Errorf("v1 不应出现 q=,实际 %s", gotQuery)
}
}
// 3) 建任务v1=PUT、v2=POST同路径方法不同
func TestTaskCreateMethodByVersion(t *testing.T) {
for _, tc := range []struct {
ver string
method string
}{
{"v1", http.MethodPut},
{"v2", http.MethodPost},
} {
var gotMethod, gotPath string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
})
p.apiVer = tc.ver
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": "3", "title": "买菜"}); err != nil {
t.Fatalf("[%s] err: %v", tc.ver, err)
}
if gotMethod != tc.method {
t.Errorf("[%s] 期望 %s实际 %s", tc.ver, tc.method, gotMethod)
}
if gotPath != "/api/"+tc.ver+"/projects/3/tasks" {
t.Errorf("[%s] 路径错误: %s", tc.ver, gotPath)
}
}
}
// 4) 改任务v2走 merge-patch只发变更字段
func TestTaskUpdateV2MergePatch(t *testing.T) {
var method, ctype string
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
method = r.Method
ctype = r.Header.Get("Content-Type")
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"id":7,"done":true}`))
})
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "done": true}); err != nil {
t.Fatalf("err: %v", err)
}
if method != http.MethodPatch {
t.Errorf("v2 应用 PATCH实际 %s", method)
}
if !strings.Contains(ctype, "merge-patch") {
t.Errorf("应使用 merge-patch 内容类型,实际 %s", ctype)
}
if len(body) != 1 || body["done"] != true {
t.Errorf("只应发送变更字段,实际 %v", body)
}
}
// 5) 改任务v2回退merge-patch 被拒 → 取回-合并-PUT
func TestTaskUpdateV2FallbackToMergePut(t *testing.T) {
var calls []string
var putBody map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case r.Method == http.MethodPatch:
w.WriteHeader(http.StatusUnsupportedMediaType)
_, _ = w.Write([]byte(`{"code":9,"message":"unsupported media type"}`))
case r.Method == http.MethodGet:
_, _ = w.Write([]byte(`{"id":7,"title":"旧标题","done":false,"priority":1}`))
case r.Method == http.MethodPut:
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &putBody)
_, _ = w.Write([]byte(`{"id":7,"title":"新标题","done":false,"priority":1}`))
default:
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
}
})
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "title": "新标题"}); err != nil {
t.Fatalf("err: %v", err)
}
want := []string{"PATCH /api/v2/tasks/7", "GET /api/v2/tasks/7", "PUT /api/v2/tasks/7"}
if len(calls) != len(want) {
t.Fatalf("调用序列不符: %v", calls)
}
for i := range want {
if calls[i] != want[i] {
t.Errorf("第 %d 步期望 %s实际 %s", i+1, want[i], calls[i])
}
}
if putBody["title"] != "新标题" {
t.Errorf("合并后的 body 应含新标题,实际 %v", putBody)
}
if putBody["priority"] != float64(1) {
t.Errorf("合并必须保留原有字段priority实际 %v", putBody)
}
}
// 6) 改任务v1没有 merge-patch必须取回-合并-POST
func TestTaskUpdateV1FetchMergePost(t *testing.T) {
var calls []string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"id":9,"title":"旧","priority":2}`))
return
}
_, _ = w.Write([]byte(`{"id":9,"title":"新","priority":2}`))
})
p.apiVer = "v1"
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "9", "title": "新"}); err != nil {
t.Fatalf("err: %v", err)
}
want := []string{"GET /api/v1/tasks/9", "POST /api/v1/tasks/9"}
if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] {
t.Fatalf("v1 应为 GET→POST实际 %v", calls)
}
}
// 7) 错误映射401 提示检查 token
func TestErrorHint401(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":11,"message":"invalid token"}`))
})
_, err := p.handleTasksList(map[string]interface{}{})
if err == nil {
t.Fatal("应返回错误")
}
msg := err.Error()
if !strings.Contains(msg, "401") || !strings.Contains(msg, "code=11") {
t.Errorf("错误信息应含状态码与 Vikunja code实际 %s", msg)
}
if !strings.Contains(msg, "token") {
t.Errorf("401 应给出 token 提示,实际 %s", msg)
}
}
// 8) 未配置 token 时应给出可操作提示,而不是发出无凭据请求
func TestMissingToken(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("未配置 token 时不应发请求")
})
p.token = ""
_, err := p.handleTasksList(map[string]interface{}{})
if err == nil || !strings.Contains(err.Error(), "vikunja.token") {
t.Fatalf("应提示配置项名,实际 %v", err)
}
}
// 9) 导入Todoist 必须走 v1即使插件默认是 v2
func TestMigrateUsesV1ForTodoist(t *testing.T) {
var path string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
_, _ = w.Write([]byte(`{"ok":true}`))
})
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "todoist", "code": "abc"}); err != nil {
t.Fatalf("err: %v", err)
}
if path != "/api/v1/migration/todoist/migrate" {
t.Errorf("Todoist 导入必须走 v1实际 %s", path)
}
}
// 10) 导入WeKan 走 v2
func TestMigrateUsesV2ForWekan(t *testing.T) {
var path, method string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
path, method = r.URL.Path, r.Method
_, _ = w.Write([]byte(`{"ok":true}`))
})
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "wekan"}); err != nil {
t.Fatalf("err: %v", err)
}
if path != "/api/v2/migration/wekan/migrate" || method != http.MethodPost {
t.Errorf("WeKan 应走 v2 POST实际 %s %s", method, path)
}
}
// 11) 时间跟踪:秒数换算成 end_time计时开始则不带 end_time
func TestTimeEntrySecondsBecomesEndTime(t *testing.T) {
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"id":1}`))
})
start := "2026-09-12T10:00:00+08:00"
if _, err := p.handleTimeEntries(map[string]interface{}{
"action": "create", "task_id": "5", "seconds": float64(600),
"start_time": start,
}); err != nil {
t.Fatalf("err: %v", err)
}
// 判据不写死字符串:按时区无关的方式比较两个时间点
sStart, err := time.Parse(time.RFC3339, start)
if err != nil {
t.Fatalf("case 自身时间写错: %v", err)
}
gotEnd, ok := body["end_time"].(string)
if !ok {
t.Fatalf("应有 end_time实际 %v", body["end_time"])
}
tEnd, err := time.Parse(time.RFC3339, gotEnd)
if err != nil {
t.Fatalf("end_time 不是 RFC3339: %q", gotEnd)
}
if diff := tEnd.Sub(sStart); diff != 10*time.Minute {
t.Errorf("end_time 应由 start_time+600s 推出,实际差值 %v", diff)
}
if _, ok := body["seconds"]; ok {
t.Errorf("TimeEntry 没有 seconds 字段,不应发送:%v", body)
}
body = nil
if _, err := p.handleTimeEntries(map[string]interface{}{"action": "timer_start", "task_id": "5"}); err != nil {
t.Fatalf("err: %v", err)
}
v, present := body["end_time"]
if !present || v != nil {
t.Errorf("计时开始应显式 end_time=nulllive timer实际 %v", body)
}
}
// 12) 时间跟踪在 v1 下应给出明确不可用提示
func TestTimeEntryUnavailableOnV1(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
p.apiVer = "v1"
_, err := p.handleTimeEntries(map[string]interface{}{"action": "list"})
if err == nil || !strings.Contains(err.Error(), "v2") {
t.Fatalf("v1 下应提示改用 v2实际 %v", err)
}
}
// 13) 标签v1 收 Label 对象、v2 收 label_id
func TestLabelBodyByVersion(t *testing.T) {
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{}`))
})
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
t.Fatalf("err: %v", err)
}
if body["label_id"] != float64(5) {
t.Errorf("v2 应发送 label_id实际 %v", body)
}
body = nil
p.apiVer = "v1"
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
t.Fatalf("err: %v", err)
}
if body["id"] != float64(5) {
t.Errorf("v1 应发送 Label 对象(id),实际 %v", body)
}
}
// 14) 时间字符串容忍today / +3d / ISO
func TestNormalizeTime(t *testing.T) {
for _, in := range []string{"today", "tomorrow", "+3d", "2026-09-12 18:00", "2026-09-12T18:00:00+08:00"} {
got := normalizeTime(in)
s, ok := got.(string)
if !ok {
t.Fatalf("%s: 期望字符串,实际 %T", in, got)
}
if _, err := time.Parse(time.RFC3339, s); err != nil {
t.Errorf("%s → %s 不是 RFC3339: %v", in, s, err)
}
}
}
// 15) 通用直通:可指定 api_versionmethod 大小写不敏感
func TestRawAPI(t *testing.T) {
var method, path string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
method, path = r.Method, r.URL.Path
_, _ = w.Write([]byte(`[]`))
})
if _, err := p.handleRawAPI(map[string]interface{}{"method": "get", "path": "projects", "api_version": "v1"}); err != nil {
t.Fatalf("err: %v", err)
}
if method != http.MethodGet || path != "/api/v1/projects" {
t.Errorf("直通参数未生效: %s %s", method, path)
}
}
// 16) 精简输出可关闭(关闭时返回原样)
func TestCompactToggle(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":1,"title":"t","description":"很长的描述","done":false}]`))
})
p.compact = false
res, err := p.handleTasksList(map[string]interface{}{})
if err != nil {
t.Fatalf("err: %v", err)
}
arr, ok := res.([]interface{})
if !ok {
t.Fatalf("关闭精简后应原样返回数组,实际 %T", res)
}
if _, ok := arr[0].(map[string]interface{})["description"]; !ok {
t.Errorf("关闭精简后应保留 description")
}
}
// ── 回归JSON body 里的 ID 必须是数字(线上实测的 422 缺口)────────────
//
// vikunja v2.6.0 实测2026-09-12
// {"project_id":"1"} → 422 expected integer at body.project_id
// {"user_id":"1"} → 422 expected integer at body.user_id
// {"username":"jianf"} → 422 unexpected property at body.username
// 旧实现把 argID() 的字符串直接塞进 bodyassignee 还额外带 username
// 于是「建任务」「指派」在 v2 下必定失败 —— 只有真调用才暴露,单测没盖到。
func TestTaskCreateSendsNumericProjectID(t *testing.T) {
var body map[string]interface{}
var raw []byte
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ = io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
})
// project_id 传 float64 —— 这正是 SDK 从 JSON 解出来的真实类型
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": float64(3), "title": "买菜"}); err != nil {
t.Fatalf("err: %v", err)
}
if _, ok := body["project_id"].(float64); !ok {
t.Errorf("project_id 必须是 JSON 数字,实际 %T=%v", body["project_id"], body["project_id"])
}
if strings.Contains(string(raw), `"project_id":"`) {
t.Errorf("出现字符串型 project_idv2 会 422 expected integer: %s", raw)
}
}
func TestAssigneeAddResolvesUsernameToNumericUserID(t *testing.T) {
var body map[string]interface{}
var raw []byte
var calls []string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/api/v2/users":
if r.URL.Query().Get("q") != "alice" {
t.Errorf("v2 用户搜索应用 q=,实际 query=%q", r.URL.RawQuery)
}
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"},{"id":9,"username":"alice2"}]`))
case "/api/v2/tasks/1/assignees":
raw, _ = io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user_id":7}`))
default:
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
}
})
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"}); err != nil {
t.Fatalf("err: %v", err)
}
if len(calls) != 2 {
t.Fatalf("应先查用户再指派,实际调用: %v", calls)
}
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
t.Errorf("user_id 必须是数字 7实际 %T=%v", body["user_id"], body["user_id"])
}
if _, ok := body["username"]; ok {
t.Errorf("v2 不接受 username 字段422 unexpected property: %s", raw)
}
}
func TestAssigneeAddNumericUserSkipsLookup(t *testing.T) {
var calls []string
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if r.URL.Path == "/api/v2/users" {
t.Errorf("传数字 ID 时不该再查用户表")
}
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user_id":7}`))
})
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "7"}); err != nil {
t.Fatalf("err: %v", err)
}
if len(calls) != 1 {
t.Errorf("应只有一次请求,实际: %v", calls)
}
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
t.Errorf("user_id 应为数字 7实际 %T=%v", body["user_id"], body["user_id"])
}
}
func TestAssigneeRemoveUsesResolvedNumericPath(t *testing.T) {
var gotPath string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/users":
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"}]`))
default:
gotPath = r.Method + " " + r.URL.Path
w.WriteHeader(http.StatusNoContent)
}
})
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "remove", "user": "alice"}); err != nil {
t.Fatalf("err: %v", err)
}
if gotPath != "DELETE /api/v2/tasks/1/assignees/7" {
t.Errorf("移除应用解析出的数字 ID实际 %q", gotPath)
}
}
func TestAssigneeAddUnknownUserGivesReadableError(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":7,"username":"bob"}]`))
})
_, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"})
if err == nil {
t.Fatal("找不到用户时必须报错,而不是发出一个注定 422 的请求")
}
if !strings.Contains(err.Error(), "找不到用户") || !strings.Contains(err.Error(), "bob") {
t.Errorf("错误信息应说明找不到并给出相近候选: %v", err)
}
}