package service import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "affiliate_dash/internal/model" "github.com/google/uuid" ) func TestCreateRechargeRequiresVoucher(t *testing.T) { db := newServiceTestDB(t) merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-novoucher", 0, -1, 100) svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir()) if _, err := svc.CreateRecharge(CreateRechargeInput{ MerchantID: merchantID, AmountCNY: 10000, }); err == nil || !strings.Contains(err.Error(), "凭证") { t.Fatalf("empty voucher should be rejected, got %v", err) } if _, err := svc.CreateRecharge(CreateRechargeInput{ MerchantID: merchantID, AmountCNY: 100, Vouchers: []string{"/uploads/a.png"}, }); err == nil || !strings.Contains(err.Error(), "10 元") { t.Fatalf("amount below minimum should be rejected, got %v", err) } app, err := svc.CreateRecharge(CreateRechargeInput{ MerchantID: merchantID, ActorUserID: 7, AmountCNY: 50000, Vouchers: []string{"/uploads/a.png"}, Note: "对公转账", }) if err != nil { t.Fatalf("create recharge: %v", err) } if app.Status != model.RechargeStatusPending || app.PointsAmount != 50000 { t.Fatalf("unexpected application: %+v", app) } } func TestReviewRechargeCreditsWalletOnce(t *testing.T) { db := newServiceTestDB(t) merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-review", 0, -1, 100) fulfillment := NewFulfillmentService(db, nil) svc := NewRechargeService(db, fulfillment, t.TempDir()) app, err := svc.CreateRecharge(CreateRechargeInput{ MerchantID: merchantID, ActorUserID: 7, AmountCNY: 100000, // 1000 元 = 100000 积分 Vouchers: []string{"/uploads/a.png"}, }) if err != nil { t.Fatalf("create recharge: %v", err) } reviewed, err := svc.ReviewRecharge(ReviewRechargeInput{ ApplicationID: app.ID, Approved: true, ReviewNote: "已核实到账", ActorUserID: 1, }) if err != nil { t.Fatalf("review approve: %v", err) } if reviewed.Status != model.RechargeStatusApproved || reviewed.PointsAmount != 100000 { t.Fatalf("unexpected reviewed app: %+v", reviewed) } wallet, err := fulfillment.GetWallet(merchantID) if err != nil { t.Fatalf("get wallet: %v", err) } if wallet.AvailableBalance != 100000 { t.Fatalf("approved recharge should credit wallet, got %d", wallet.AvailableBalance) } // 重复审核应被拒绝且不再入账 if _, err := svc.ReviewRecharge(ReviewRechargeInput{ ApplicationID: app.ID, Approved: true, ActorUserID: 1, }); err == nil || !strings.Contains(err.Error(), "已审核") { t.Fatalf("repeat review should be rejected, got %v", err) } wallet, _ = fulfillment.GetWallet(merchantID) if wallet.AvailableBalance != 100000 { t.Fatalf("repeat review should not credit again, got %d", wallet.AvailableBalance) } } func TestAlertChannelCRUD(t *testing.T) { db := newServiceTestDB(t) merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-channel", 0, -1, 100) svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir()) // 非法渠道类型 if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ChannelType: "sms", Name: "短信"}); err == nil { t.Fatalf("invalid channel type should be rejected") } // 缺少 webhook url if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ChannelType: model.AlertChannelDingtalk}); err == nil || !strings.Contains(err.Error(), "Webhook URL") { t.Fatalf("missing webhook url should be rejected, got %v", err) } dingtalk, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ ChannelType: model.AlertChannelDingtalk, Name: "财务群", Enabled: true, Config: model.AlertChannelConfig{WebhookURL: "https://oapi.dingtalk.com/robot/send?access_token=test"}, }) if err != nil { t.Fatalf("create dingtalk channel: %v", err) } bark, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ ChannelType: model.AlertChannelBark, Name: "老板手机", Enabled: true, Config: model.AlertChannelConfig{Server: "https://api.day.app", Key: "abc123"}, }) if err != nil { t.Fatalf("create bark channel: %v", err) } if bark.Config.Key != "abc123" { t.Fatalf("bark config should persist, got %+v", bark.Config) } // 跨商户隔离 otherMerchant, _ := seedFulfillmentMerchant(t, db, "merchant-alert-channel-other", 0, -1, 100) if _, err := svc.UpdateAlertChannel(otherMerchant, dingtalk.ID, AlertChannelInput{ ChannelType: model.AlertChannelDingtalk, Config: model.AlertChannelConfig{WebhookURL: "https://oapi.dingtalk.com/robot/send?access_token=hack"}, }); err == nil { t.Fatalf("other merchant should not update channel") } if err := svc.DeleteAlertChannel(otherMerchant, dingtalk.ID); err != nil { t.Fatalf("other merchant delete should not error (no-op): %v", err) } list, err := svc.ListAlertChannels(merchantID) if err != nil || len(list) != 2 { t.Fatalf("expected 2 channels, got %d, err=%v", len(list), err) } } func TestSendChannelMessagePayload(t *testing.T) { got := make(chan map[string]interface{}, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} _ = json.NewDecoder(r.Body).Decode(&body) got <- body w.WriteHeader(http.StatusOK) })) defer srv.Close() // 钉钉格式 channel := model.AlertNotifyChannel{ChannelType: model.AlertChannelDingtalk, Config: model.AlertChannelConfig{WebhookURL: srv.URL}} if err := sendChannelMessage(channel, "余额告警", "内容"); err != nil { t.Fatalf("send dingtalk: %v", err) } body := <-got if body["msgtype"] != "text" || body["text"].(map[string]interface{})["content"] != "内容" { t.Fatalf("unexpected dingtalk payload: %+v", body) } // 飞书格式 channel = model.AlertNotifyChannel{ChannelType: model.AlertChannelFeishu, Config: model.AlertChannelConfig{WebhookURL: srv.URL}} if err := sendChannelMessage(channel, "余额告警", "内容"); err != nil { t.Fatalf("send feishu: %v", err) } body = <-got if body["msg_type"] != "text" || body["content"].(map[string]interface{})["text"] != "内容" { t.Fatalf("unexpected feishu payload: %+v", body) } // Bark 格式(服务器为空时使用默认服务器会请求外部网络,这里显式指定测试服务器) channel = model.AlertNotifyChannel{ChannelType: model.AlertChannelBark, Config: model.AlertChannelConfig{Server: srv.URL, Key: "device-1"}} if err := sendChannelMessage(channel, "标题", "内容"); err != nil { t.Fatalf("send bark: %v", err) } body = <-got if body["device_key"] != "device-1" || body["title"] != "标题" || body["body"] != "内容" { t.Fatalf("unexpected bark payload: %+v", body) } } func TestAlertNotificationPolicy(t *testing.T) { db := newServiceTestDB(t) merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-policy", 0, -1, 100) fulfillment := NewFulfillmentService(db, nil) svc := NewRechargeService(db, fulfillment, t.TempDir()) notified := make(chan struct{}, 16) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { notified <- struct{}{} w.WriteHeader(http.StatusOK) })) defer srv.Close() if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ ChannelType: model.AlertChannelWebhook, Name: "测试", Enabled: true, Config: model.AlertChannelConfig{WebhookURL: srv.URL}, }); err != nil { t.Fatalf("create channel: %v", err) } if _, err := svc.SaveAlertConfig(merchantID, SaveAlertInput{ Enabled: true, ThresholdPoints: 100, NotifyIntervalMinutes: 30, MaxNotifications: 3, }); err != nil { t.Fatalf("save alert config: %v", err) } // 固定时间基线,逐步推进 base := time.Date(2026, 8, 4, 10, 0, 0, 0, time.Local) advance := func(d time.Duration) { timeNow = func() time.Time { return base.Add(d) } } t.Cleanup(func() { timeNow = time.Now }) advance(0) // 余额 0 < 阈值 100:第一次触发 → 通知 1 次 svc.CheckLowBalanceAndNotify(merchantID) select { case <-notified: default: t.Fatalf("first check should notify") } // 10 分钟后仍在间隔内 → 不通知 advance(10 * time.Minute) svc.CheckLowBalanceAndNotify(merchantID) select { case <-notified: t.Fatalf("within interval should not notify") default: } // 31 分钟后超出间隔 → 第二次通知 advance(31 * time.Minute) svc.CheckLowBalanceAndNotify(merchantID) select { case <-notified: default: t.Fatalf("after interval should notify") } // 61 分钟 → 第三次通知 advance(61 * time.Minute) svc.CheckLowBalanceAndNotify(merchantID) select { case <-notified: default: t.Fatalf("third notification expected") } // 91 分钟 → 已达最大次数 3,不再通知 advance(91 * time.Minute) svc.CheckLowBalanceAndNotify(merchantID) select { case <-notified: t.Fatalf("max notifications reached, should stop") default: } // 充值恢复余额 ≥ 阈值 → 重置计数 if _, err := fulfillment.AdjustWallet(WalletAdjustInput{ MerchantID: merchantID, ActorUserID: 1, Amount: 500, IdempotencyKey: "recover-" + uuid.NewString(), }); err != nil { t.Fatalf("adjust wallet: %v", err) } advance(92 * time.Minute) svc.CheckLowBalanceAndNotify(merchantID) var cfg model.MerchantAlertConfig if err := db.Where("merchant_id = ?", merchantID).First(&cfg).Error; err != nil { t.Fatalf("query config: %v", err) } if cfg.NotificationCount != 0 || cfg.LastNotifiedAt != nil { t.Fatalf("recovery should reset notification state, got count=%d", cfg.NotificationCount) } // 又扣回低余额 → 重新开始新一轮通知 if _, err := fulfillment.AdjustWallet(WalletAdjustInput{ MerchantID: merchantID, ActorUserID: 1, Amount: -500, IdempotencyKey: "down-" + uuid.NewString(), }); err != nil { t.Fatalf("adjust wallet down: %v", err) } advance(93 * time.Minute) svc.CheckLowBalanceAndNotify(merchantID) select { case <-notified: default: t.Fatalf("new round should notify again") } } func TestFormatThousands(t *testing.T) { cases := []struct { in int64 want string }{ {0, "0"}, {999, "999"}, {1000, "1,000"}, {990, "990"}, {1234567, "1,234,567"}, {-1234567, "-1,234,567"}, } for _, tc := range cases { if got := formatThousands(tc.in); got != tc.want { t.Fatalf("formatThousands(%d) = %q, want %q", tc.in, got, tc.want) } } } func TestDetectImageExt(t *testing.T) { cases := []struct { name string data []byte want string }{ {"jpeg", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00}, "jpg"}, {"png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, "png"}, {"gif", []byte{'G', 'I', 'F', '8', '9', 'a'}, "gif"}, {"webp", []byte{'R', 'I', 'F', 'F', 0x00, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P'}, "webp"}, {"fake", []byte{'<', 's', 'c', 'r', 'i', 'p', 't', '>'}, ""}, {"empty", []byte{}, ""}, } for _, tc := range cases { if got := detectImageExt(tc.data); got != tc.want { t.Fatalf("%s: detectImageExt = %q, want %q", tc.name, got, tc.want) } } } func TestReviewRechargeReject(t *testing.T) { db := newServiceTestDB(t) merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-reject", 0, -1, 100) fulfillment := NewFulfillmentService(db, nil) svc := NewRechargeService(db, fulfillment, t.TempDir()) app, err := svc.CreateRecharge(CreateRechargeInput{ MerchantID: merchantID, ActorUserID: 7, AmountCNY: 10000, Vouchers: []string{"/uploads/a.png"}, }) if err != nil { t.Fatalf("create recharge: %v", err) } reviewed, err := svc.ReviewRecharge(ReviewRechargeInput{ ApplicationID: app.ID, Approved: false, ReviewNote: "凭证不清晰", ActorUserID: 1, }) if err != nil { t.Fatalf("review reject: %v", err) } if reviewed.Status != model.RechargeStatusRejected { t.Fatalf("expected rejected, got %+v", reviewed) } wallet, _ := fulfillment.GetWallet(merchantID) if wallet.AvailableBalance != 0 { t.Fatalf("rejected recharge should not credit, got %d", wallet.AvailableBalance) } }