340 lines
12 KiB
Go
340 lines
12 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"affiliate_dash/internal/model"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// newRockRestockMockServer 模拟上游:刷新返回空对象(视为未绑定),提货详情返回固定绑定链接。
|
||
func newRockRestockMockServer(t *testing.T) *httptest.Server {
|
||
t.Helper()
|
||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
switch {
|
||
case strings.HasPrefix(r.URL.Path, "/api/redeem/"):
|
||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"id": 151153,
|
||
"redeem_code": "CODE-RESTOCK",
|
||
"bind_url": "https://gp.qq.com/cp/mock_bind",
|
||
})
|
||
case strings.HasSuffix(r.URL.Path, "/refresh"):
|
||
_ = json.NewEncoder(w).Encode(map[string]interface{}{})
|
||
default:
|
||
w.WriteHeader(http.StatusNotFound)
|
||
}
|
||
}))
|
||
}
|
||
|
||
// mustCreateRestockToken 创建一条测试 Token,状态与上游编号可指定。
|
||
func mustCreateRestockToken(t *testing.T, db *gorm.DB, upstreamID, redeemCode, status string) model.RockToken {
|
||
t.Helper()
|
||
token := model.RockToken{
|
||
UpstreamTokenID: upstreamID,
|
||
Token: "jwt-" + upstreamID,
|
||
RedeemCode: redeemCode,
|
||
Color: model.RockColorBlack,
|
||
Status: status,
|
||
}
|
||
if err := db.Create(&token).Error; err != nil {
|
||
t.Fatalf("创建测试 Token 失败: %v", err)
|
||
}
|
||
return token
|
||
}
|
||
|
||
// TestReleaseUnboundUsingToken_NextAssignPrioritizesRestocked 验证人工释放回库存的 Token
|
||
// 会写入 restocked_at,并在下一次分配时优先于更早入库的普通 Token。
|
||
func TestReleaseUnboundUsingToken_NextAssignPrioritizesRestocked(t *testing.T) {
|
||
db := newRockTestDB(t)
|
||
server := newRockRestockMockServer(t)
|
||
defer server.Close()
|
||
|
||
svc := NewRockService(db, NewRockClient(server.URL))
|
||
setRockTestBearer(t, svc, "bearer-test")
|
||
ctx := context.Background()
|
||
|
||
// 先入库存的普通 Token(id 更小),正常情况下会先被分配。
|
||
mustCreateRestockToken(t, db, "restock-normal", "CODE-RESTOCK-NORMAL", model.RockStatusAvailable)
|
||
|
||
usingAt := time.Now().Add(-time.Hour)
|
||
releasedToken := model.RockToken{
|
||
UpstreamTokenID: "restock-released",
|
||
Token: "jwt-restock-released",
|
||
RedeemCode: "CODE-RESTOCK-RELEASED",
|
||
Color: model.RockColorBlack,
|
||
Status: model.RockStatusUsing,
|
||
AssignedOrderNo: "ORDER-RESTOCK-OLD",
|
||
ExpectedUID: "11111111",
|
||
UsingAt: &usingAt,
|
||
}
|
||
if err := db.Create(&releasedToken).Error; err != nil {
|
||
t.Fatalf("创建使用中 Token 失败: %v", err)
|
||
}
|
||
|
||
if err := svc.ReleaseUnboundUsingToken(ctx, releasedToken.ID, false, nil); err != nil {
|
||
t.Fatalf("释放回库存失败: %v", err)
|
||
}
|
||
|
||
var released model.RockToken
|
||
if err := db.First(&released, releasedToken.ID).Error; err != nil {
|
||
t.Fatalf("读取释放后的 Token 失败: %v", err)
|
||
}
|
||
if released.Status != model.RockStatusAvailable {
|
||
t.Fatalf("释放后应为 available,实际 %s", released.Status)
|
||
}
|
||
if released.RestockedAt == nil {
|
||
t.Fatalf("人工释放回库存必须写入 restocked_at")
|
||
}
|
||
|
||
assigned, _, err := svc.AssignTokenForOrder(ctx, "ORDER-RESTOCK-NEW", "22222222")
|
||
if err != nil {
|
||
t.Fatalf("分配 Token 失败: %v", err)
|
||
}
|
||
if assigned.ID != releasedToken.ID {
|
||
t.Fatalf("应优先分配人工释放回库存的 Token(id=%d),实际选中 id=%d", releasedToken.ID, assigned.ID)
|
||
}
|
||
if assigned.RestockedAt != nil {
|
||
t.Fatalf("分配后必须清空 restocked_at,实际 %v", assigned.RestockedAt)
|
||
}
|
||
|
||
var stored model.RockToken
|
||
if err := db.First(&stored, assigned.ID).Error; err != nil {
|
||
t.Fatalf("读取分配结果失败: %v", err)
|
||
}
|
||
if stored.Status != model.RockStatusUsing || stored.RestockedAt != nil {
|
||
t.Fatalf("数据库分配结果不符: status=%s restocked_at=%v", stored.Status, stored.RestockedAt)
|
||
}
|
||
}
|
||
|
||
// TestReleaseUnboundUsingToken_ForceAlsoMarksRestocked 验证强制释放同样产生优先权。
|
||
func TestReleaseUnboundUsingToken_ForceAlsoMarksRestocked(t *testing.T) {
|
||
db := newRockTestDB(t)
|
||
server := newBoundReleaseServer(t)
|
||
defer server.Close()
|
||
|
||
svc := NewRockService(db, NewRockClient(server.URL))
|
||
setRockTestBearer(t, svc, "bearer-test")
|
||
ctx := context.Background()
|
||
|
||
usingAt := time.Now().Add(-30 * time.Minute)
|
||
token := model.RockToken{
|
||
UpstreamTokenID: "restock-force",
|
||
Token: "jwt-restock-force",
|
||
RedeemCode: "CODE-RESTOCK-FORCE",
|
||
Color: model.RockColorBlack,
|
||
Status: model.RockStatusUsing,
|
||
AssignedOrderNo: "ORDER-RESTOCK-FORCE",
|
||
ExpectedUID: "33333333",
|
||
UsingAt: &usingAt,
|
||
}
|
||
if err := db.Create(&token).Error; err != nil {
|
||
t.Fatalf("创建使用中 Token 失败: %v", err)
|
||
}
|
||
|
||
if err := svc.ReleaseUnboundUsingToken(ctx, token.ID, true, nil); err != nil {
|
||
t.Fatalf("强制释放失败: %v", err)
|
||
}
|
||
|
||
var released model.RockToken
|
||
if err := db.First(&released, token.ID).Error; err != nil {
|
||
t.Fatalf("读取释放后的 Token 失败: %v", err)
|
||
}
|
||
if released.Status != model.RockStatusAvailable || released.RestockedAt == nil {
|
||
t.Fatalf("强制释放后应回到 available 且写入 restocked_at,实际 status=%s restocked_at=%v",
|
||
released.Status, released.RestockedAt)
|
||
}
|
||
}
|
||
|
||
// TestAssignTokenForOrder_RestockedTokensFollowFIFO 验证多个释放 Token 按最早回库优先排序。
|
||
func TestAssignTokenForOrder_RestockedTokensFollowFIFO(t *testing.T) {
|
||
db := newRockTestDB(t)
|
||
server := newRockRestockMockServer(t)
|
||
defer server.Close()
|
||
|
||
svc := NewRockService(db, NewRockClient(server.URL))
|
||
setRockTestBearer(t, svc, "bearer-test")
|
||
ctx := context.Background()
|
||
|
||
// 普通库存 id 最小,但没有任何优先权,应排到最后。
|
||
plain := mustCreateRestockToken(t, db, "fifo-plain", "CODE-FIFO-PLAIN", model.RockStatusAvailable)
|
||
later := mustCreateRestockToken(t, db, "fifo-later", "CODE-FIFO-LATER", model.RockStatusAvailable)
|
||
earlier := mustCreateRestockToken(t, db, "fifo-earlier", "CODE-FIFO-EARLIER", model.RockStatusAvailable)
|
||
|
||
if err := db.Model(&model.RockToken{}).Where("id = ?", later.ID).
|
||
Update("restocked_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
||
t.Fatalf("写入较晚回库时间失败: %v", err)
|
||
}
|
||
if err := db.Model(&model.RockToken{}).Where("id = ?", earlier.ID).
|
||
Update("restocked_at", time.Now().Add(-2*time.Hour)).Error; err != nil {
|
||
t.Fatalf("写入较早回库时间失败: %v", err)
|
||
}
|
||
|
||
wantOrder := []uint{earlier.ID, later.ID, plain.ID}
|
||
for i, wantID := range wantOrder {
|
||
orderNo := fmt.Sprintf("ORDER-FIFO-%d", i)
|
||
assigned, _, err := svc.AssignTokenForOrder(ctx, orderNo, fmt.Sprintf("9000000%d", i))
|
||
if err != nil {
|
||
t.Fatalf("第 %d 次分配失败: %v", i+1, err)
|
||
}
|
||
if assigned.ID != wantID {
|
||
t.Fatalf("第 %d 次分配应选中 id=%d,实际 id=%d", i+1, wantID, assigned.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestResetMismatchToken_ClearsRestockPriority 验证解冻重置不产生优先权。
|
||
func TestResetMismatchToken_ClearsRestockPriority(t *testing.T) {
|
||
db := newRockTestDB(t)
|
||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||
ctx := context.Background()
|
||
|
||
stale := time.Now().Add(-time.Hour)
|
||
token := model.RockToken{
|
||
UpstreamTokenID: "reset-restock",
|
||
Token: "jwt-reset-restock",
|
||
RedeemCode: "CODE-RESET-RESTOCK",
|
||
Color: model.RockColorBlack,
|
||
Status: model.RockStatusMismatch,
|
||
AssignedOrderNo: "ORDER-RESET-RESTOCK",
|
||
MismatchAt: &stale,
|
||
MismatchReason: "历史不一致",
|
||
RestockedAt: &stale,
|
||
}
|
||
if err := db.Create(&token).Error; err != nil {
|
||
t.Fatalf("创建 mismatch Token 失败: %v", err)
|
||
}
|
||
|
||
if err := svc.ResetMismatchToken(ctx, token.ID, nil); err != nil {
|
||
t.Fatalf("解冻重置失败: %v", err)
|
||
}
|
||
|
||
var reset model.RockToken
|
||
if err := db.First(&reset, token.ID).Error; err != nil {
|
||
t.Fatalf("读取重置结果失败: %v", err)
|
||
}
|
||
if reset.Status != model.RockStatusAvailable {
|
||
t.Fatalf("重置后应为 available,实际 %s", reset.Status)
|
||
}
|
||
if reset.RestockedAt != nil {
|
||
t.Fatalf("解冻重置不应产生优先权,restocked_at=%v", reset.RestockedAt)
|
||
}
|
||
}
|
||
|
||
// TestUpdateTokenRemark_ManualAvailableClearsRestockPriority 验证人工备注改回可用不产生优先权。
|
||
func TestUpdateTokenRemark_ManualAvailableClearsRestockPriority(t *testing.T) {
|
||
db := newRockTestDB(t)
|
||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||
ctx := context.Background()
|
||
|
||
stale := time.Now().Add(-time.Hour)
|
||
token := model.RockToken{
|
||
UpstreamTokenID: "remark-restock",
|
||
Token: "jwt-remark-restock",
|
||
RedeemCode: "CODE-REMARK-RESTOCK",
|
||
Color: model.RockColorBlack,
|
||
Status: model.RockStatusAbnormal,
|
||
RestockedAt: &stale,
|
||
}
|
||
if err := db.Create(&token).Error; err != nil {
|
||
t.Fatalf("创建 abnormal Token 失败: %v", err)
|
||
}
|
||
|
||
if _, err := svc.UpdateTokenRemark(ctx, token.ID, "人工改回可用", model.RockStatusAvailable, nil); err != nil {
|
||
t.Fatalf("人工改回可用失败: %v", err)
|
||
}
|
||
|
||
var updated model.RockToken
|
||
if err := db.First(&updated, token.ID).Error; err != nil {
|
||
t.Fatalf("读取更新结果失败: %v", err)
|
||
}
|
||
if updated.Status != model.RockStatusAvailable {
|
||
t.Fatalf("人工更新后应为 available,实际 %s", updated.Status)
|
||
}
|
||
if updated.RestockedAt != nil {
|
||
t.Fatalf("人工改回可用不应产生优先权,restocked_at=%v", updated.RestockedAt)
|
||
}
|
||
}
|
||
|
||
// TestRockProviderCancel_ClearsRestockPriority 验证订单取消回滚不产生优先权。
|
||
func TestRockProviderCancel_ClearsRestockPriority(t *testing.T) {
|
||
db := newRockTestDB(t)
|
||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||
provider := NewRockProvider(svc)
|
||
ctx := context.Background()
|
||
|
||
stale := time.Now().Add(-time.Hour)
|
||
token := model.RockToken{
|
||
UpstreamTokenID: "cancel-restock",
|
||
Token: "jwt-cancel-restock",
|
||
RedeemCode: "CODE-CANCEL-RESTOCK",
|
||
Color: model.RockColorBlack,
|
||
Status: model.RockStatusUsing,
|
||
AssignedOrderNo: "ORDER-CANCEL-RESTOCK",
|
||
UsingAt: &stale,
|
||
RestockedAt: &stale,
|
||
}
|
||
if err := db.Create(&token).Error; err != nil {
|
||
t.Fatalf("创建使用中 Token 失败: %v", err)
|
||
}
|
||
|
||
if err := provider.Cancel(ctx, &model.FulfillmentOrder{OrderNo: "ORDER-CANCEL-RESTOCK"}); err != nil {
|
||
t.Fatalf("取消订单回滚失败: %v", err)
|
||
}
|
||
|
||
var canceled model.RockToken
|
||
if err := db.First(&canceled, token.ID).Error; err != nil {
|
||
t.Fatalf("读取取消结果失败: %v", err)
|
||
}
|
||
if canceled.Status != model.RockStatusAvailable {
|
||
t.Fatalf("取消后应为 available,实际 %s", canceled.Status)
|
||
}
|
||
if canceled.RestockedAt != nil {
|
||
t.Fatalf("订单取消回滚不应产生优先权,restocked_at=%v", canceled.RestockedAt)
|
||
}
|
||
}
|
||
|
||
// TestRestoreRockTokens_ClearsRestockPriority 验证回收站恢复不产生优先权。
|
||
func TestRestoreRockTokens_ClearsRestockPriority(t *testing.T) {
|
||
db := newRockTestDB(t)
|
||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||
ctx := context.Background()
|
||
|
||
stale := time.Now().Add(-time.Hour)
|
||
token := mustCreateRestockToken(t, db, "restore-restock", "CODE-RESTORE-RESTOCK", model.RockStatusAvailable)
|
||
if err := db.Model(&model.RockToken{}).Where("id = ?", token.ID).
|
||
Update("restocked_at", stale).Error; err != nil {
|
||
t.Fatalf("写入历史优先权失败: %v", err)
|
||
}
|
||
if err := db.Delete(&token).Error; err != nil {
|
||
t.Fatalf("软删除 Token 失败: %v", err)
|
||
}
|
||
|
||
result, err := svc.RestoreRockTokens(ctx, []uint{token.ID}, 42)
|
||
if err != nil {
|
||
t.Fatalf("恢复回收站 Token 失败: %v", err)
|
||
}
|
||
if result.Restored != 1 {
|
||
t.Fatalf("期望恢复 1 条,实际 %d 条(跳过 %+v)", result.Restored, result.SkippedItems)
|
||
}
|
||
|
||
var restored model.RockToken
|
||
if err := db.First(&restored, token.ID).Error; err != nil {
|
||
t.Fatalf("读取恢复结果失败: %v", err)
|
||
}
|
||
if restored.DeletedAt.Valid {
|
||
t.Fatalf("恢复后 deleted_at 必须为空")
|
||
}
|
||
if restored.RestockedAt != nil {
|
||
t.Fatalf("回收站恢复不应产生优先权,restocked_at=%v", restored.RestockedAt)
|
||
}
|
||
}
|