Files
sundynix-pets/pets-be/internal/httpapi/tasks_test.go
T
Blizzard 3175936266
ci-cd / verify (push) Successful in 2m18s
ci-cd / deploy (push) Skipped
feat: support shared pet care tasks and smooth daily page return
2026-10-05 20:20:05 +08:00

146 lines
5.2 KiB
Go

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)
}
}