package service import ( "context" "testing" "affiliate_dash/internal/model" ) func restoreSkipReasons(items []RockTokenRestoreSkipItem) map[uint]string { reasons := make(map[uint]string, len(items)) for _, item := range items { reasons[item.ID] = item.Reason } return reasons } func TestRestoreRockTokens_UnhidesDeletedToken(t *testing.T) { db := newRockTestDB(t) svc := NewRockService(db, NewRockClient("https://example.com")) ctx := context.Background() available := createDeleteTestToken(t, svc, "95001", model.RockStatusAvailable) redeemed := createDeleteTestToken(t, svc, "95002", model.RockStatusRedeemed) if _, err := svc.DeleteRockTokens(ctx, []uint{available.ID, redeemed.ID}, false, 7); err != nil { t.Fatalf("软删除失败: %v", err) } deleted, total, err := svc.ListDeletedRockTokens(ctx, 1, 20, "", "") if err != nil { t.Fatalf("查询回收站失败: %v", err) } if total != 2 || len(deleted) != 2 { t.Fatalf("回收站应有 2 条,实际 total=%d len=%d", total, len(deleted)) } var deletedAtSet bool for _, row := range deleted { if row.DeletedAt.Valid && row.DeletedAtText != "" { deletedAtSet = true } } if !deletedAtSet { t.Fatal("回收站列表应带出 deleted_at 字符串") } stats, err := svc.GetStats(ctx) if err != nil { t.Fatalf("查询统计失败: %v", err) } if stats.DeletedStock != 2 || stats.AvailableStock != 0 { t.Fatalf("删除后统计不符: deleted=%d available=%d", stats.DeletedStock, stats.AvailableStock) } result, err := svc.RestoreRockTokens(ctx, []uint{available.ID}, 7) if err != nil { t.Fatalf("恢复失败: %v", err) } if result.Requested != 1 || result.Restored != 1 || result.Skipped != 0 { t.Fatalf("恢复结果不符: %+v", result) } _, total, err = svc.ListTokens(ctx, 1, 20, "", "") if err != nil { t.Fatalf("查询列表失败: %v", err) } if total != 1 { t.Fatalf("恢复后列表应有 1 条,实际 %d", total) } stats, err = svc.GetStats(ctx) if err != nil { t.Fatalf("查询统计失败: %v", err) } if stats.AvailableStock != 1 || stats.DeletedStock != 1 { t.Fatalf("恢复后统计不符: available=%d deleted=%d", stats.AvailableStock, stats.DeletedStock) } // 状态筛选同样作用于回收站。 deleted, total, err = svc.ListDeletedRockTokens(ctx, 1, 20, model.RockStatusRedeemed, "") if err != nil { t.Fatalf("按状态查询回收站失败: %v", err) } if total != 1 || len(deleted) != 1 || deleted[0].ID != redeemed.ID { t.Fatalf("回收站状态筛选不符: total=%d %+v", total, deleted) } } func TestRestoreRockTokens_SkipsActiveRecords(t *testing.T) { db := newRockTestDB(t) svc := NewRockService(db, NewRockClient("https://example.com")) ctx := context.Background() active := createDeleteTestToken(t, svc, "96001", model.RockStatusAvailable) usingToken := createDeleteTestToken(t, svc, "96002", model.RockStatusUsing) if _, err := svc.DeleteRockTokens(ctx, []uint{usingToken.ID}, true, 7); err != nil { t.Fatalf("强制删除失败: %v", err) } result, err := svc.RestoreRockTokens(ctx, []uint{active.ID, usingToken.ID, 999999}, 7) if err != nil { t.Fatalf("恢复失败: %v", err) } if result.Requested != 3 || result.Restored != 1 || result.Skipped != 2 { t.Fatalf("部分恢复结果不符: %+v", result) } if len(result.StatusCounts) != 1 || result.StatusCounts[0].Status != model.RockStatusUsing { t.Fatalf("恢复状态分布不符: %+v", result.StatusCounts) } reasons := restoreSkipReasons(result.SkippedItems) if reasons[active.ID] != RockRestoreSkipNotDeleted { t.Fatalf("未删除记录应跳过 not_deleted,实际 %q", reasons[active.ID]) } if reasons[999999] != RockRestoreSkipNotDeleted { t.Fatalf("不存在的记录应跳过 not_deleted,实际 %q", reasons[999999]) } var restored model.RockToken if err := db.First(&restored, usingToken.ID).Error; err != nil { t.Fatalf("恢复后的 Token 应可普通查询: %v", err) } if restored.Status != model.RockStatusUsing { t.Fatalf("恢复后状态应保持 using,实际 %s", restored.Status) } deletedRows, total, err := svc.ListDeletedRockTokens(ctx, 1, 20, "", "") if err != nil { t.Fatalf("查询回收站失败: %v", err) } if total != 0 || len(deletedRows) != 0 { t.Fatalf("回收站应已清空,实际 %d", total) } } func TestRestoreRockTokens_SkipsOrderConflict(t *testing.T) { db := newRockTestDB(t) svc := NewRockService(db, NewRockClient("https://example.com")) ctx := context.Background() old := createDeleteTestToken(t, svc, "97001", model.RockStatusBound) if _, err := svc.DeleteRockTokens(ctx, []uint{old.ID}, true, 7); err != nil { t.Fatalf("强制删除失败: %v", err) } // 订单还没有被别的 Token 占用时,恢复应当成功。 result, err := svc.RestoreRockTokens(ctx, []uint{old.ID}, 7) if err != nil { t.Fatalf("无冲突恢复失败: %v", err) } if result.Restored != 1 || result.Skipped != 0 { t.Fatalf("无冲突恢复结果不符: %+v", result) } // 删除后同一订单被新 Token 接管,再恢复旧 Token 必须跳过。 if _, err := svc.DeleteRockTokens(ctx, []uint{old.ID}, true, 7); err != nil { t.Fatalf("再次删除失败: %v", err) } fresh := model.RockToken{ UpstreamTokenID: "97002", Token: "tk-97002", RedeemCode: "rc-97002", Color: model.RockColorBlack, Status: model.RockStatusBound, AssignedOrderNo: old.AssignedOrderNo, } if err := db.Create(&fresh).Error; err != nil { t.Fatalf("创建新 Token 失败: %v", err) } result, err = svc.RestoreRockTokens(ctx, []uint{old.ID}, 7) if err != nil { t.Fatalf("冲突恢复应返回部分成功而不是错误: %v", err) } if result.Restored != 0 || result.Skipped != 1 { t.Fatalf("订单冲突应跳过: %+v", result) } if reasons := restoreSkipReasons(result.SkippedItems); reasons[old.ID] != RockRestoreSkipOrderConflict { t.Fatalf("跳过原因应为 order_conflict,实际 %q", reasons[old.ID]) } var stillDeleted model.RockToken if err := db.Unscoped().First(&stillDeleted, old.ID).Error; err != nil { t.Fatalf("读取旧记录失败: %v", err) } if !stillDeleted.DeletedAt.Valid { t.Fatal("订单冲突时不应恢复旧 Token") } } func TestRestoreRockTokens_SkipsDuplicateUpstream(t *testing.T) { db := newRockTestDB(t) svc := NewRockService(db, NewRockClient("https://example.com")) ctx := context.Background() old := createDeleteTestToken(t, svc, "98001", model.RockStatusAvailable) if _, err := svc.DeleteRockTokens(ctx, []uint{old.ID}, false, 7); err != nil { t.Fatalf("软删除失败: %v", err) } // 部分唯一索引允许已删除记录与新记录共用同一个 upstream_token_id。 fresh := model.RockToken{ UpstreamTokenID: "98001", Token: "tk-98001-b", RedeemCode: "rc-98001-b", Color: model.RockColorBlack, Status: model.RockStatusAvailable, } if err := db.Create(&fresh).Error; err != nil { t.Fatalf("创建同编号新 Token 失败: %v", err) } result, err := svc.RestoreRockTokens(ctx, []uint{old.ID}, 7) if err != nil { t.Fatalf("恢复失败: %v", err) } if result.Restored != 0 || result.Skipped != 1 { t.Fatalf("上游编号冲突应跳过: %+v", result) } if reasons := restoreSkipReasons(result.SkippedItems); reasons[old.ID] != RockRestoreSkipDuplicateUpstream { t.Fatalf("跳过原因应为 duplicate_upstream,实际 %q", reasons[old.ID]) } }