package gateway import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" ) // reminderTestEnv 构建一个注入了真实 automations.Service 的网关 // (P0-22 之前该服务在生产里没有任何创建入口,提醒因此不可达)。 func reminderTestEnv(t *testing.T) (*httptest.Server, *automations.Service, string) { t.Helper() auto, err := automations.NewService(automations.Config{}, automations.Deps{Bus: bus.New()}) if err != nil { t.Fatalf("new automations service: %v", err) } _, ts, jwt := buildEnv(t, nil, Deps{Automations: auto}) return ts, auto, signAny(t, jwt, "acct", auth.RoleOwner) } // postReminder 调用提醒创建端点并返回状态码与原始响应体。 func postReminder(t *testing.T, ts *httptest.Server, tok string, body any) (int, []byte) { t.Helper() resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/automations/reminders", body, tok) return resp.StatusCode, raw } // TestAdminCreateReminder_AppearsInServiceList 是 P0-22 的真断言: // POST /api/v1/admin/automations/reminders 之后,该提醒真的出现在 // ReminderStore(Service.Reminders / List)里,且时间与文案一致 —— // 而不是只回一个 201。 func TestAdminCreateReminder_AppearsInServiceList(t *testing.T) { t.Parallel() ts, auto, tok := reminderTestEnv(t) at := time.Now().Add(2 * time.Hour).Truncate(time.Second) status, raw := postReminder(t, ts, tok, map[string]any{"id": "rem-drink", "at": at.Format(time.RFC3339), "message": "提醒主人喝水"}) if status != http.StatusCreated { t.Fatalf("status = %d, want 201, body = %s", status, raw) } var envelope struct { Data struct { ID string `json:"id"` At string `json:"at"` Message string `json:"message"` Kind string `json:"kind"` } `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil { t.Fatalf("decode: %v (%s)", err, raw) } if envelope.Data.ID != "rem-drink" || envelope.Data.Kind != "reminder" || envelope.Data.Message != "提醒主人喝水" { t.Fatalf("created reminder = %+v", envelope.Data) } if envelope.Data.At != at.Format(time.RFC3339) { t.Fatalf("at = %q, want %q", envelope.Data.At, at.Format(time.RFC3339)) } list := auto.Reminders() if len(list) != 1 { t.Fatalf("Reminders() = %+v, want exactly the created one", list) } if list[0].ID != "rem-drink" || list[0].Message != "提醒主人喝水" || !list[0].At.Equal(at) { t.Fatalf("reminder in store = %+v", list[0]) } // 管理读面(面板的自动化页)也必须看到它,且 kind 为 reminder。 resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/automations", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("automations list status = %d, body = %s", resp.StatusCode, raw) } var listEnv struct { Data []AdminAutomationRecord `json:"data"` } if err := json.Unmarshal(raw, &listEnv); err != nil { t.Fatalf("decode automations list: %v (%s)", err, raw) } found := false for _, rec := range listEnv.Data { if rec.ID == "rem-drink" && rec.Kind == "reminder" && rec.Name == "提醒主人喝水" { found = true } } if !found { t.Fatalf("reminder not visible in /admin/automations: %+v", listEnv.Data) } } // TestAdminCreateReminder_GeneratesIDWhenOmitted 证明面板不必自己保证 // id 唯一:省略 id 时后端生成,且两次创建得到两条不同提醒。 func TestAdminCreateReminder_GeneratesIDWhenOmitted(t *testing.T) { t.Parallel() ts, auto, tok := reminderTestEnv(t) at := time.Now().Add(time.Hour).Truncate(time.Second).Format(time.RFC3339) ids := make([]string, 0, 2) for i := 0; i < 2; i++ { status, raw := postReminder(t, ts, tok, map[string]any{"at": at, "message": fmt.Sprintf("第 %d 次", i)}) if status != http.StatusCreated { t.Fatalf("status = %d, want 201, body = %s", status, raw) } var envelope struct { Data struct { ID string `json:"id"` } `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil { t.Fatalf("decode: %v (%s)", err, raw) } if !strings.HasPrefix(envelope.Data.ID, "rem-") { t.Fatalf("generated id = %q, want rem- prefix", envelope.Data.ID) } ids = append(ids, envelope.Data.ID) } if ids[0] == ids[1] { t.Fatalf("generated ids collide: %q", ids[0]) } if n := len(auto.Reminders()); n != 2 { t.Fatalf("Reminders() = %d, want 2", n) } } // TestAdminCreateReminder_InvalidInputReturns400 逐条钉住非法输入的显式 // 400;重复 id 是 409(已有同名提醒),绝不静默覆盖。 func TestAdminCreateReminder_InvalidInputReturns400(t *testing.T) { t.Parallel() ts, auto, tok := reminderTestEnv(t) at := time.Now().Add(time.Hour).Truncate(time.Second) cases := []struct { name string body map[string]any want int code string }{ {"missing message", map[string]any{"at": at.Format(time.RFC3339)}, http.StatusBadRequest, "bad_request"}, {"missing at", map[string]any{"message": "x"}, http.StatusBadRequest, "bad_request"}, {"at not rfc3339", map[string]any{"at": "明天早上", "message": "x"}, http.StatusBadRequest, "invalid_at"}, {"at unix millis", map[string]any{"at": "1760000000000", "message": "x"}, http.StatusBadRequest, "invalid_at"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() status, raw := postReminder(t, ts, tok, tc.body) if status != tc.want { t.Fatalf("status = %d, want %d, body = %s", status, tc.want, raw) } var envErr errorEnvelope if err := json.Unmarshal(raw, &envErr); err != nil { t.Fatalf("decode error envelope: %v (%s)", err, raw) } if envErr.Error.Code != tc.code { t.Fatalf("error code = %q, want %q (%s)", envErr.Error.Code, tc.code, raw) } }) } if n := len(auto.Reminders()); n != 0 { t.Fatalf("reminders after rejected writes = %d, want 0", n) } body := map[string]any{"id": "dup", "at": at.Format(time.RFC3339), "message": "第一次"} if status, raw := postReminder(t, ts, tok, body); status != http.StatusCreated { t.Fatalf("first status = %d, body = %s", status, raw) } status, raw := postReminder(t, ts, tok, body) if status != http.StatusConflict { t.Fatalf("duplicate status = %d, want 409, body = %s", status, raw) } var envErr errorEnvelope if err := json.Unmarshal(raw, &envErr); err != nil { t.Fatalf("decode duplicate envelope: %v (%s)", err, raw) } if envErr.Error.Code != "already_exists" { t.Fatalf("duplicate code = %q, want already_exists (%s)", envErr.Error.Code, raw) } if n := len(auto.Reminders()); n != 1 { t.Fatalf("reminders = %d, want 1 (duplicate must not overwrite)", n) } }