package httpapi import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/sundynix/pets-be/internal/config" "github.com/sundynix/pets-be/internal/database" "github.com/sundynix/pets-be/internal/model" ) func TestTaskCompletionCannotBeUndone(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPut, "/completion", bytes.NewBufferString(`{"date":"2026-10-05","completed":false}`)) api := New(nil, nil, &config.Config{}) api.setTaskCompletion(c) if rec.Code != http.StatusBadRequest { t.Fatalf("undo status %d", rec.Code) } } // All fixtures and writes are rolled back, including inner batch transactions. func TestTaskCompletionPostgres(t *testing.T) { if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" { t.Skip("PostgreSQL integration disabled") } cfg, err := config.Load() if err != nil { t.Fatal(err) } db, err := database.Open(cfg.Postgres, true) if err != nil { t.Fatal(err) } sqlDB, _ := db.DB() defer sqlDB.Close() tx := db.Begin() if tx.Error != nil { t.Fatal(tx.Error) } defer tx.Rollback() // Verify the nullable pet association migration without changing live schema. if err := tx.AutoMigrate(&model.CareTask{}); err != nil { t.Fatal(err) } user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"} if err := tx.Create(&user).Error; err != nil { t.Fatal(err) } pet := model.Pet{UserID: user.ID, Name: "测试毛孩", Species: "dog", Gender: "male"} if err := tx.Create(&pet).Error; err != nil { t.Fatal(err) } tasks := []model.CareTask{{UserID: user.ID, PetID: &pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: &pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}} if err := tx.Create(&tasks).Error; err != nil { t.Fatal(err) } api := New(tx, nil, cfg) request := func(method, route, id string, body gin.H, handler gin.HandlerFunc) *httptest.ResponseRecorder { t.Helper() encoded, _ := json.Marshal(body) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Set("actor_id", user.ID) c.Params = gin.Params{{Key: "id", Value: id}} c.Request = httptest.NewRequest(method, route, bytes.NewReader(encoded)) handler(c) return rec } allBody := gin.H{"all_pets": true, "title": "全部换水", "category": "water", "time_of_day": "10:00", "repeat_rule": "daily"} rec := request("POST", "/tasks", "", allBody, api.createTask) if rec.Code != 201 { t.Fatalf("all-pet creation: %d %s", rec.Code, rec.Body.String()) } var allTask model.CareTask if err := tx.Where("user_id = ? AND title = ?", user.ID, "全部换水").First(&allTask).Error; err != nil || allTask.PetID != nil { t.Fatalf("all-pet association should be null: %v", err) } // Single-pet filters include shared tasks; other users' tasks remain isolated. rec = request("GET", "/tasks?date=2026-10-05&pet_id="+pet.ID, "", nil, api.listTasks) var listing struct { Data struct { Items []struct { ID string `json:"id"` AllPets bool `json:"all_pets"` } `json:"items"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &listing); err != nil || rec.Code != 200 || len(listing.Data.Items) != 3 { t.Fatalf("pet filter omitted shared task: %s", rec.Body.String()) } if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, gin.H{"pet_id": pet.ID, "title": "单只换水"}, api.updateTask); rec.Code != 200 { t.Fatalf("switch to single pet: %s", rec.Body.String()) } if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, allBody, api.updateTask); rec.Code != 200 { t.Fatalf("switch to all pets: %s", rec.Body.String()) } conflicting := gin.H{"all_pets": true, "pet_id": pet.ID, "title": "冲突选择"} if rec = request("POST", "/tasks", "", conflicting, api.createTask); rec.Code != 400 { t.Fatalf("ambiguous selection accepted: %s", rec.Body.String()) } call := func(ids []string) int { t.Helper() body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids}) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Set("actor_id", user.ID) c.Request = httptest.NewRequest(http.MethodPut, "/tasks/completions", bytes.NewReader(body)) api.completeTasks(c) return rec.Code } if status := call([]string{tasks[0].ID, uuid.NewString()}); status != 400 { t.Fatalf("invalid batch status %d", status) } var count int64 tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count) if count != 0 { t.Fatal("invalid batch partially completed") } ids := []string{tasks[0].ID, tasks[1].ID} for range 2 { if status := call(ids); status != 200 { t.Fatalf("batch status %d", status) } } tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count) if count != 2 { t.Fatalf("retry duplicated completions: %d", count) } if status := call([]string{allTask.ID}); status != 200 { t.Fatalf("all-pet completion status %d", status) } tx.Model(&model.TaskCompletion{}).Where("task_id = ?", allTask.ID).Count(&count) if count != 1 { t.Fatalf("shared task should have one completion: %d", count) } }