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