From 6c303abd58a9c685dea587e8b7374afcbe35b030 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 4 Mar 2026 18:35:32 +0800 Subject: [PATCH] =?UTF-8?q?test(expiry):=20=E5=AE=8C=E6=88=90=20#31=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=E4=B8=8E?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87=E6=8F=90=E5=8D=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/expiry/handler_branches_test.go | 69 ++++++ internal/expiry/handler_test.go | 205 +++++++++++++++ internal/expiry/repository.go | 20 +- internal/expiry/repository_test.go | 302 +++++++++++++++++++++++ internal/expiry/service_branches_test.go | 130 ++++++++++ internal/expiry/service_test.go | 160 ++++++++++++ internal/expiry/test_helper_test.go | 33 +++ 7 files changed, 909 insertions(+), 10 deletions(-) create mode 100644 internal/expiry/handler_branches_test.go create mode 100644 internal/expiry/handler_test.go create mode 100644 internal/expiry/repository_test.go create mode 100644 internal/expiry/service_branches_test.go create mode 100644 internal/expiry/service_test.go create mode 100644 internal/expiry/test_helper_test.go diff --git a/internal/expiry/handler_branches_test.go b/internal/expiry/handler_branches_test.go new file mode 100644 index 0000000..003f97a --- /dev/null +++ b/internal/expiry/handler_branches_test.go @@ -0,0 +1,69 @@ +package expiry + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestHandler_HealthzAndBadPathBranches(t *testing.T) { + r := newTestRouter(t) + + // 覆盖 healthz。 + { + db := newTestDB(t) + handler := NewHandler(NewService(NewRepository(db))) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + handler.Healthz(c) + if w.Code != http.StatusOK { + t.Fatalf("healthz status=%d", w.Code) + } + } + + // 覆盖错误分支。 + badIDDelete := requestJSON(t, r, http.MethodDelete, "/api/expiry/items/abc", nil) + if badIDDelete.Code != http.StatusBadRequest { + t.Fatalf("expected bad id 400, got %d", badIDDelete.Code) + } + + badDateCreate := requestJSON(t, r, http.MethodPost, "/api/expiry/items", map[string]interface{}{ + "name": "错误日期", + "category": "food", + "production_date": "2026/01/01", + "expiry_date": "2030-01-01", + }) + if badDateCreate.Code != http.StatusBadRequest { + t.Fatalf("expected bad date 400, got %d", badDateCreate.Code) + } + + badStatus := requestJSON(t, r, http.MethodPost, "/api/expiry/items/1/status", map[string]interface{}{ + "status": "invalid", + }) + if badStatus.Code != http.StatusBadRequest && badStatus.Code != http.StatusNotFound { + t.Fatalf("expected bad status 400/404, got %d", badStatus.Code) + } + + badList := requestJSON(t, r, http.MethodGet, "/api/expiry/items?status=bad", nil) + if badList.Code != http.StatusBadRequest { + t.Fatalf("expected bad filter 400, got %d", badList.Code) + } +} + +func TestHandler_HelperWriters(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + writeExpiryServerError(c) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } + + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + writeExpirySuccess(c2, "ok", gin.H{"x": 1}) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w2.Code) + } +} diff --git a/internal/expiry/handler_test.go b/internal/expiry/handler_test.go new file mode 100644 index 0000000..c2a8e18 --- /dev/null +++ b/internal/expiry/handler_test.go @@ -0,0 +1,205 @@ +package expiry + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "wx_service/internal/middleware" + "wx_service/internal/model" +) + +type apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func newTestRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + + db := newTestDB(t) + repo := NewRepository(db) + service := NewService(repo) + handler := NewHandler(service) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(middleware.ContextCurrentUserKey, &model.User{ID: 1, MiniProgramID: 1}) + c.Next() + }) + + api := r.Group("/api/expiry") + { + api.GET("/summary", handler.GetSummary) + api.GET("/items", handler.GetItems) + api.POST("/items", handler.CreateItem) + api.PUT("/items/:id", handler.UpdateItem) + api.DELETE("/items/:id", handler.DeleteItem) + api.POST("/items/:id/status", handler.UpdateStatus) + api.GET("/settings", handler.GetSettings) + api.POST("/settings", handler.UpdateSettings) + } + + return r +} + +func requestJSON(t *testing.T, r http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { + t.Helper() + + var payload []byte + if body != nil { + var err error + payload, err = json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + } + + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) apiResponse { + t.Helper() + + var resp apiResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v, body=%s", err, w.Body.String()) + } + return resp +} + +func TestHandler_ItemsFlow(t *testing.T) { + r := newTestRouter(t) + + createResp := requestJSON(t, r, http.MethodPost, "/api/expiry/items", map[string]interface{}{ + "name": "酸奶", + "category": "food", + "expiry_date": "2030-06-01", + "quantity": 2, + "location": "冰箱", + }) + + if createResp.Code != http.StatusOK { + t.Fatalf("create status code = %d, body=%s", createResp.Code, createResp.Body.String()) + } + + createBody := decodeResponse(t, createResp) + if createBody.Code != 0 { + t.Fatalf("create business code = %d", createBody.Code) + } + + var created struct { + ID uint `json:"id"` + } + if err := json.Unmarshal(createBody.Data, &created); err != nil { + t.Fatalf("decode create data: %v", err) + } + if created.ID == 0 { + t.Fatalf("expected created id > 0") + } + + updateResp := requestJSON(t, r, http.MethodPut, "/api/expiry/items/1", map[string]interface{}{ + "name": "酸奶(更新)", + "category": "food", + "expiry_date": "2030-06-02", + "quantity": 1, + }) + if updateResp.Code != http.StatusOK { + t.Fatalf("update status code = %d, body=%s", updateResp.Code, updateResp.Body.String()) + } + + notFoundUpdateResp := requestJSON(t, r, http.MethodPut, "/api/expiry/items/999", map[string]interface{}{ + "name": "不存在", + "category": "food", + "expiry_date": "2030-06-02", + "quantity": 1, + }) + if notFoundUpdateResp.Code != http.StatusNotFound { + t.Fatalf("update not found code = %d, body=%s", notFoundUpdateResp.Code, notFoundUpdateResp.Body.String()) + } + + listResp := requestJSON(t, r, http.MethodGet, "/api/expiry/items?status=all&page=1&page_size=20", nil) + if listResp.Code != http.StatusOK { + t.Fatalf("list status code = %d, body=%s", listResp.Code, listResp.Body.String()) + } + + listBody := decodeResponse(t, listResp) + if listBody.Code != 0 { + t.Fatalf("list business code = %d", listBody.Code) + } + + statusResp := requestJSON(t, r, http.MethodPost, "/api/expiry/items/1/status", map[string]string{ + "status": "used", + }) + if statusResp.Code != http.StatusOK { + t.Fatalf("status update code = %d, body=%s", statusResp.Code, statusResp.Body.String()) + } + + delResp := requestJSON(t, r, http.MethodDelete, "/api/expiry/items/1", nil) + if delResp.Code != http.StatusOK { + t.Fatalf("delete code = %d, body=%s", delResp.Code, delResp.Body.String()) + } +} + +func TestHandler_SettingsAndSummary(t *testing.T) { + r := newTestRouter(t) + + settingsResp := requestJSON(t, r, http.MethodGet, "/api/expiry/settings", nil) + if settingsResp.Code != http.StatusOK { + t.Fatalf("get settings code=%d body=%s", settingsResp.Code, settingsResp.Body.String()) + } + settingsBody := decodeResponse(t, settingsResp) + if settingsBody.Code != 0 { + t.Fatalf("get settings business code=%d", settingsBody.Code) + } + + updateSettingsResp := requestJSON(t, r, http.MethodPost, "/api/expiry/settings", map[string]interface{}{ + "remind_days": []int{10, 5, 1}, + }) + if updateSettingsResp.Code != http.StatusOK { + t.Fatalf("update settings code=%d body=%s", updateSettingsResp.Code, updateSettingsResp.Body.String()) + } + updateSettingsBody := decodeResponse(t, updateSettingsResp) + if updateSettingsBody.Code != 0 { + t.Fatalf("update settings business code=%d", updateSettingsBody.Code) + } + + summaryResp := requestJSON(t, r, http.MethodGet, "/api/expiry/summary", nil) + if summaryResp.Code != http.StatusOK { + t.Fatalf("summary code=%d body=%s", summaryResp.Code, summaryResp.Body.String()) + } + summaryBody := decodeResponse(t, summaryResp) + if summaryBody.Code != 0 { + t.Fatalf("summary business code=%d", summaryBody.Code) + } +} + +func TestHandler_BadRequest(t *testing.T) { + r := newTestRouter(t) + + badCreate := requestJSON(t, r, http.MethodPost, "/api/expiry/items", map[string]interface{}{ + "name": "", + "category": "food", + }) + if badCreate.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", badCreate.Code, badCreate.Body.String()) + } + + badSettings := requestJSON(t, r, http.MethodPost, "/api/expiry/settings", map[string]interface{}{ + "remind_days": []int{0}, + }) + if badSettings.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", badSettings.Code, badSettings.Body.String()) + } +} diff --git a/internal/expiry/repository.go b/internal/expiry/repository.go index b970bf5..3e02ffc 100644 --- a/internal/expiry/repository.go +++ b/internal/expiry/repository.go @@ -151,7 +151,9 @@ func (r *Repository) GetSummary(userID uint) (map[string]int, error) { now := dateOnly(time.Now()) sevenDaysLater := now.AddDate(0, 0, 7) - base := r.db.Model(&ExpiryItem{}).Where("user_id = ?", userID) + base := func() *gorm.DB { + return r.db.Model(&ExpiryItem{}).Where("user_id = ?", userID) + } count := func(tx *gorm.DB) (int64, error) { var v int64 @@ -161,35 +163,35 @@ func (r *Repository) GetSummary(userID uint) (map[string]int, error) { return v, nil } - totalItems, err := count(base.Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded})) + totalItems, err := count(base().Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded})) if err != nil { return nil, fmt.Errorf("count total items: %w", err) } - expiringSoon, err := count(base.Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded}). + expiringSoon, err := count(base().Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded}). Where("expiry_date BETWEEN ? AND ?", now, sevenDaysLater)) if err != nil { return nil, fmt.Errorf("count expiring items: %w", err) } - expired, err := count(base.Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded}). + expired, err := count(base().Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded}). Where("expiry_date < ?", now)) if err != nil { return nil, fmt.Errorf("count expired items: %w", err) } - normal, err := count(base.Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded}). + normal, err := count(base().Where("status NOT IN ?", []string{StatusUsed, StatusDiscarded}). Where("expiry_date > ?", sevenDaysLater)) if err != nil { return nil, fmt.Errorf("count normal items: %w", err) } - used, err := count(base.Where("status = ?", StatusUsed)) + used, err := count(base().Where("status = ?", StatusUsed)) if err != nil { return nil, fmt.Errorf("count used items: %w", err) } - discarded, err := count(base.Where("status = ?", StatusDiscarded)) + discarded, err := count(base().Where("status = ?", StatusDiscarded)) if err != nil { return nil, fmt.Errorf("count discarded items: %w", err) } @@ -250,9 +252,7 @@ func (r *Repository) UpdateSettings(userID uint, remindDays []int) (*ExpiryUserS } settings.RemindDays = copyIntSlice(remindDays) - if err := r.db.Model(&ExpiryUserSettings{}). - Where("user_id = ?", userID). - Update("remind_days", settings.RemindDays).Error; err != nil { + if err := r.db.Save(settings).Error; err != nil { return nil, fmt.Errorf("update expiry settings: %w", err) } return settings, nil diff --git a/internal/expiry/repository_test.go b/internal/expiry/repository_test.go new file mode 100644 index 0000000..03b8337 --- /dev/null +++ b/internal/expiry/repository_test.go @@ -0,0 +1,302 @@ +package expiry + +import ( + "testing" + "time" +) + +func TestRepository_CreateFindUpdateDelete(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + + expiryDate := mustDate(t, "2030-01-10") + shelf := 30 + + item := &ExpiryItem{ + UserID: 100, + MiniProgramID: 1, + Name: "牛奶", + Category: CategoryFood, + ExpiryDate: expiryDate, + ShelfLifeDays: &shelf, + Quantity: 2, + Status: StatusNormal, + } + + if err := repo.Create(item); err != nil { + t.Fatalf("create item: %v", err) + } + if item.ID == 0 { + t.Fatalf("expected item id > 0") + } + + found, err := repo.FindByID(item.ID, item.UserID) + if err != nil { + t.Fatalf("find by id: %v", err) + } + if found.Name != "牛奶" { + t.Fatalf("unexpected name: %s", found.Name) + } + + found.Name = "牛奶(更新)" + if err := repo.Update(found); err != nil { + t.Fatalf("update item: %v", err) + } + + updated, err := repo.FindByID(item.ID, item.UserID) + if err != nil { + t.Fatalf("find updated item: %v", err) + } + if updated.Name != "牛奶(更新)" { + t.Fatalf("unexpected updated name: %s", updated.Name) + } + + if err := repo.UpdateStatus(item.ID, item.UserID, StatusUsed); err != nil { + t.Fatalf("update status: %v", err) + } + + afterStatus, err := repo.FindByID(item.ID, item.UserID) + if err != nil { + t.Fatalf("find after status: %v", err) + } + if afterStatus.Status != StatusUsed { + t.Fatalf("expected status used, got %s", afterStatus.Status) + } + + if err := repo.Delete(item.ID, item.UserID); err != nil { + t.Fatalf("delete item: %v", err) + } + + _, err = repo.FindByID(item.ID, item.UserID) + if err != ErrExpiryItemNotFound { + t.Fatalf("expected ErrExpiryItemNotFound after delete, got %v", err) + } +} + +func TestRepository_FindByUserAndSummary(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + + now := dateOnly(mustDate(t, "2030-01-01")) + + items := []ExpiryItem{ + { + UserID: 200, + MiniProgramID: 1, + Name: "过期食品", + Category: CategoryFood, + ExpiryDate: now.AddDate(0, 0, -1), + Quantity: 1, + Status: StatusNormal, + }, + { + UserID: 200, + MiniProgramID: 1, + Name: "临期药品", + Category: CategoryMedicine, + ExpiryDate: now.AddDate(0, 0, 3), + Quantity: 1, + Status: StatusNormal, + }, + { + UserID: 200, + MiniProgramID: 1, + Name: "正常化妆品", + Category: CategoryCosmetic, + ExpiryDate: now.AddDate(0, 0, 30), + Quantity: 1, + Status: StatusNormal, + }, + { + UserID: 200, + MiniProgramID: 1, + Name: "已使用", + Category: CategoryOther, + ExpiryDate: now.AddDate(0, 0, 30), + Quantity: 1, + Status: StatusUsed, + }, + { + UserID: 200, + MiniProgramID: 1, + Name: "已丢弃", + Category: CategoryOther, + ExpiryDate: now.AddDate(0, 0, 30), + Quantity: 1, + Status: StatusDiscarded, + }, + } + + for idx := range items { + if err := repo.Create(&items[idx]); err != nil { + t.Fatalf("create item %d: %v", idx, err) + } + } + + list, total, err := repo.FindByUser(200, map[string]interface{}{ + "status": "all", + "category": "all", + "sort": "expiry_date", + }, 1, 20) + if err != nil { + t.Fatalf("find by user all: %v", err) + } + if total != 5 || len(list) != 5 { + t.Fatalf("expected 5 items total/list, got total=%d len=%d", total, len(list)) + } + + usedList, usedTotal, err := repo.FindByUser(200, map[string]interface{}{ + "status": StatusUsed, + "category": "all", + "sort": "expiry_date", + }, 1, 20) + if err != nil { + t.Fatalf("find used list: %v", err) + } + if usedTotal != 1 || len(usedList) != 1 { + t.Fatalf("expected used list 1, got total=%d len=%d", usedTotal, len(usedList)) + } + + summary, err := repo.GetSummary(200) + if err != nil { + t.Fatalf("get summary: %v", err) + } + + if summary["total_items"] != 3 { + t.Fatalf("expected total_items=3, got %d", summary["total_items"]) + } + + if summary["used"] != 1 || summary["discarded"] != 1 { + t.Fatalf("unexpected used/discarded: %+v", summary) + } +} + +func TestRepository_SettingsCRUD(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + + settings, err := repo.GetSettings(300) + if err != nil { + t.Fatalf("get empty settings: %v", err) + } + if settings != nil { + t.Fatalf("expected nil settings for new user") + } + + saved, err := repo.UpdateSettings(300, []int{7, 3, 1}) + if err != nil { + t.Fatalf("create settings: %v", err) + } + if len(saved.RemindDays) != 3 { + t.Fatalf("expected 3 remind days, got %d", len(saved.RemindDays)) + } + + updated, err := repo.UpdateSettings(300, []int{10, 5, 2}) + if err != nil { + t.Fatalf("update settings: %v", err) + } + + if len(updated.RemindDays) != 3 || updated.RemindDays[0] != 10 { + t.Fatalf("unexpected remind days: %+v", updated.RemindDays) + } + + loaded, err := repo.GetSettings(300) + if err != nil { + t.Fatalf("reload settings: %v", err) + } + if len(loaded.RemindDays) != 3 || loaded.RemindDays[0] != 10 { + t.Fatalf("unexpected loaded remind days: %+v", loaded.RemindDays) + } +} + +func TestRepository_ErrorAndFilterBranches(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + + if err := repo.Delete(999, 1); err != ErrExpiryItemNotFound { + t.Fatalf("expected delete not found, got %v", err) + } + if err := repo.UpdateStatus(999, 1, StatusUsed); err != ErrExpiryItemNotFound { + t.Fatalf("expected update status not found, got %v", err) + } + + _, err := repo.FindByID(999, 1) + if err != ErrExpiryItemNotFound { + t.Fatalf("expected find not found, got %v", err) + } + + now := dateOnly(time.Now()) + sevenDaysLater := now.AddDate(0, 0, 7) + items := []ExpiryItem{ + { + UserID: 400, + MiniProgramID: 1, + Name: "过期", + Category: CategoryFood, + ExpiryDate: now.AddDate(0, 0, -1), + Quantity: 1, + }, + { + UserID: 400, + MiniProgramID: 1, + Name: "临期", + Category: CategoryFood, + ExpiryDate: now.AddDate(0, 0, 1), + Quantity: 1, + }, + { + UserID: 400, + MiniProgramID: 1, + Name: "正常", + Category: CategoryMedicine, + ExpiryDate: sevenDaysLater.AddDate(0, 0, 3), + Quantity: 1, + }, + } + + for i := range items { + if err := repo.Create(&items[i]); err != nil { + t.Fatalf("seed item %d: %v", i, err) + } + } + + expiredList, _, err := repo.FindByUser(400, map[string]interface{}{ + "status": StatusExpired, + "sort": "created_at", + }, 1, 10) + if err != nil { + t.Fatalf("find expired: %v", err) + } + if len(expiredList) != 1 { + t.Fatalf("expected 1 expired, got %d", len(expiredList)) + } + + expiringList, _, err := repo.FindByUser(400, map[string]interface{}{ + "status": StatusExpiring, + }, 1, 10) + if err != nil { + t.Fatalf("find expiring: %v", err) + } + if len(expiringList) != 1 { + t.Fatalf("expected 1 expiring, got %d", len(expiringList)) + } + + normalList, _, err := repo.FindByUser(400, map[string]interface{}{ + "status": StatusNormal, + "category": CategoryMedicine, + }, 1, 10) + if err != nil { + t.Fatalf("find normal: %v", err) + } + if len(normalList) != 1 { + t.Fatalf("expected 1 normal, got %d", len(normalList)) + } + + summary, err := repo.GetSummary(400) + if err != nil { + t.Fatalf("get summary: %v", err) + } + if summary["expired"] != 1 || summary["expiring_soon"] != 1 || summary["normal"] != 1 { + t.Fatalf("unexpected summary: %+v", summary) + } +} diff --git a/internal/expiry/service_branches_test.go b/internal/expiry/service_branches_test.go new file mode 100644 index 0000000..edc5d60 --- /dev/null +++ b/internal/expiry/service_branches_test.go @@ -0,0 +1,130 @@ +package expiry + +import ( + "testing" +) + +func TestService_HelperBranches(t *testing.T) { + t.Run("normalize filters", func(t *testing.T) { + got := normalizeFilters(ItemFilters{}) + if got.Page != 1 || got.PageSize != 20 || got.Status != "all" || got.Category != "all" || got.Sort != "expiry_date" { + t.Fatalf("unexpected normalized filters: %+v", got) + } + + capped := normalizeFilters(ItemFilters{Page: 1, PageSize: 1000, Status: "all", Category: "all", Sort: "expiry_date"}) + if capped.PageSize != 100 { + t.Fatalf("expected page size capped at 100, got %d", capped.PageSize) + } + }) + + t.Run("validate filters", func(t *testing.T) { + if err := validateFilters(ItemFilters{Status: "all", Category: "all", Sort: "expiry_date"}); err != nil { + t.Fatalf("unexpected valid filters error: %v", err) + } + + if err := validateFilters(ItemFilters{Status: "x", Category: "all", Sort: "expiry_date"}); err != ErrExpiryFilterStatusInvalid { + t.Fatalf("expected status invalid, got %v", err) + } + if err := validateFilters(ItemFilters{Status: "all", Category: "x", Sort: "expiry_date"}); err != ErrExpiryFilterCategoryInvalid { + t.Fatalf("expected category invalid, got %v", err) + } + if err := validateFilters(ItemFilters{Status: "all", Category: "all", Sort: "x"}); err != ErrExpiryFilterSortInvalid { + t.Fatalf("expected sort invalid, got %v", err) + } + }) + + t.Run("validate base fields", func(t *testing.T) { + if err := validateBaseFields("名称", CategoryFood, 1, "位置", "备注"); err != nil { + t.Fatalf("expected valid fields, got %v", err) + } + if err := validateBaseFields("", CategoryFood, 1, "", ""); err != ErrExpiryNameInvalid { + t.Fatalf("expected name invalid, got %v", err) + } + if err := validateBaseFields("名称", "bad", 1, "", ""); err != ErrExpiryCategoryInvalid { + t.Fatalf("expected category invalid, got %v", err) + } + if err := validateBaseFields("名称", CategoryFood, -1, "", ""); err != ErrExpiryQuantityInvalid { + t.Fatalf("expected quantity invalid, got %v", err) + } + }) + + t.Run("apply expiry date", func(t *testing.T) { + prod := mustDate(t, "2031-01-01") + shelf := 5 + expiry := mustDate(t, "2031-01-20") + + item := &ExpiryItem{ProductionDate: &prod} + if err := applyExpiryDate(item, &expiry, &shelf); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if item.ExpiryDate.Format("2006-01-02") != "2031-01-06" { + t.Fatalf("expected computed expiry, got %s", item.ExpiryDate.Format("2006-01-02")) + } + + item2 := &ExpiryItem{} + if err := applyExpiryDate(item2, &expiry, nil); err != nil { + t.Fatalf("expected no error on expiry only, got %v", err) + } + + item3 := &ExpiryItem{} + if err := applyExpiryDate(item3, nil, nil); err != ErrExpiryDateRequired { + t.Fatalf("expected ErrExpiryDateRequired, got %v", err) + } + }) + + t.Run("category and remind days", func(t *testing.T) { + if !isValidCategory(CategoryCosmetic) || isValidCategory("invalid") { + t.Fatalf("isValidCategory result unexpected") + } + + remindDays, err := validateRemindDays([]int{10, 10, 3, 1}) + if err != nil { + t.Fatalf("validate remind days error: %v", err) + } + if len(remindDays) != 3 { + t.Fatalf("expected deduped len=3, got %d", len(remindDays)) + } + + if _, err := validateRemindDays([]int{}); err != ErrExpiryRemindDaysInvalid { + t.Fatalf("expected remind days invalid for empty, got %v", err) + } + }) +} + +func TestService_UpdateItemPreserveStatus(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + service := NewService(repo) + + expiryDate := mustDate(t, "2031-02-01") + created, err := service.CreateItem(88, CreateItemRequest{ + MiniProgramID: 1, + Name: "牙膏", + Category: CategoryOther, + ExpiryDate: &expiryDate, + Quantity: 1, + }) + if err != nil { + t.Fatalf("create item: %v", err) + } + + if err := service.UpdateItemStatus(created.ID, 88, StatusDiscarded); err != nil { + t.Fatalf("update status discarded: %v", err) + } + + newExpiry := mustDate(t, "2031-03-01") + updated, err := service.UpdateItem(created.ID, 88, UpdateItemRequest{ + MiniProgramID: 1, + Name: "牙膏(更新)", + Category: CategoryOther, + ExpiryDate: &newExpiry, + Quantity: 1, + }) + if err != nil { + t.Fatalf("update discarded item: %v", err) + } + + if updated.Status != StatusDiscarded { + t.Fatalf("expected discarded status preserved, got %s", updated.Status) + } +} diff --git a/internal/expiry/service_test.go b/internal/expiry/service_test.go new file mode 100644 index 0000000..1867a56 --- /dev/null +++ b/internal/expiry/service_test.go @@ -0,0 +1,160 @@ +package expiry + +import "testing" + +func TestService_CreateItemAndGetItems(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + service := NewService(repo) + + productionDate := mustDate(t, "2030-01-01") + shelfLife := 10 + + created, err := service.CreateItem(1, CreateItemRequest{ + MiniProgramID: 1, + Name: "测试牛奶", + Category: CategoryFood, + ProductionDate: &productionDate, + ShelfLifeDays: &shelfLife, + Quantity: 2, + Location: "冰箱", + Remark: "服务层测试", + }) + if err != nil { + t.Fatalf("create item: %v", err) + } + + if created.ExpiryDate.Format("2006-01-02") != "2030-01-11" { + t.Fatalf("unexpected expiry date: %s", created.ExpiryDate.Format("2006-01-02")) + } + + list, err := service.GetItems(1, ItemFilters{ + Status: "all", + Category: "all", + Sort: "expiry_date", + Page: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("get items: %v", err) + } + + if list.Total != 1 || len(list.Items) != 1 { + t.Fatalf("expected 1 item, got total=%d len=%d", list.Total, len(list.Items)) + } + + if list.Items[0].DaysLeft <= 0 { + t.Fatalf("expected days_left > 0, got %d", list.Items[0].DaysLeft) + } +} + +func TestService_UpdateDeleteAndStatus(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + service := NewService(repo) + + expiryDate := mustDate(t, "2030-02-01") + created, err := service.CreateItem(2, CreateItemRequest{ + MiniProgramID: 1, + Name: "测试药品", + Category: CategoryMedicine, + ExpiryDate: &expiryDate, + Quantity: 1, + }) + if err != nil { + t.Fatalf("create item: %v", err) + } + + newExpiry := mustDate(t, "2030-03-01") + updated, err := service.UpdateItem(created.ID, 2, UpdateItemRequest{ + MiniProgramID: 1, + Name: "测试药品(更新)", + Category: CategoryMedicine, + ExpiryDate: &newExpiry, + Quantity: 1, + }) + if err != nil { + t.Fatalf("update item: %v", err) + } + + if updated.Name != "测试药品(更新)" { + t.Fatalf("unexpected updated name: %s", updated.Name) + } + + if err := service.UpdateItemStatus(created.ID, 2, StatusUsed); err != nil { + t.Fatalf("update item status: %v", err) + } + + got, err := service.GetItem(created.ID, 2) + if err != nil { + t.Fatalf("get item: %v", err) + } + if got.Status != StatusUsed { + t.Fatalf("expected used status, got %s", got.Status) + } + + if err := service.DeleteItem(created.ID, 2); err != nil { + t.Fatalf("delete item: %v", err) + } + + _, err = service.GetItem(created.ID, 2) + if err != ErrExpiryItemNotFound { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestService_Validation(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + service := NewService(repo) + + expiryDate := mustDate(t, "2030-02-01") + + _, err := service.CreateItem(3, CreateItemRequest{ + MiniProgramID: 1, + Name: "", + Category: CategoryFood, + ExpiryDate: &expiryDate, + Quantity: 1, + }) + if err != ErrExpiryNameInvalid { + t.Fatalf("expected ErrExpiryNameInvalid, got %v", err) + } + + _, err = service.GetItems(3, ItemFilters{Status: "invalid", Category: "all", Sort: "expiry_date", Page: 1, PageSize: 20}) + if err != ErrExpiryFilterStatusInvalid { + t.Fatalf("expected ErrExpiryFilterStatusInvalid, got %v", err) + } + + err = service.UpdateItemStatus(1, 3, "unknown") + if err != ErrExpiryStatusInvalid { + t.Fatalf("expected ErrExpiryStatusInvalid, got %v", err) + } +} + +func TestService_Settings(t *testing.T) { + db := newTestDB(t) + repo := NewRepository(db) + service := NewService(repo) + + defaults, err := service.GetSettings(10) + if err != nil { + t.Fatalf("get default settings: %v", err) + } + if len(defaults.RemindDays) != 3 { + t.Fatalf("expected default remind_days len=3, got %d", len(defaults.RemindDays)) + } + + _, err = service.UpdateSettings(10, []int{0, 31}) + if err != ErrExpiryRemindDaysInvalid { + t.Fatalf("expected ErrExpiryRemindDaysInvalid, got %v", err) + } + + updated, err := service.UpdateSettings(10, []int{10, 10, 3, 1}) + if err != nil { + t.Fatalf("update settings: %v", err) + } + if len(updated.RemindDays) != 3 { + t.Fatalf("expected deduped remind_days len=3, got %d", len(updated.RemindDays)) + } +} diff --git a/internal/expiry/test_helper_test.go b/internal/expiry/test_helper_test.go new file mode 100644 index 0000000..2eec789 --- /dev/null +++ b/internal/expiry/test_helper_test.go @@ -0,0 +1,33 @@ +package expiry + +import ( + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.AutoMigrate(&ExpiryItem{}, &ExpiryUserSettings{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + return db +} + +func mustDate(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + t.Fatalf("parse date %s: %v", value, err) + } + return parsed +}