1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
package com.ruoyi.auth.service;
 
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
 
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import com.ruoyi.auth.config.UniversalCodeProperties;
import com.ruoyi.auth.service.sms.SmsSender;
import com.ruoyi.common.core.constant.CacheConstants;
import com.ruoyi.common.core.constant.SecurityConstants;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.redis.service.RedisService;
import com.ruoyi.system.api.RemoteAccountUserService;
import com.ruoyi.system.api.domain.AccountMember;
 
/**
 * 短信验证码服务单元测试
 *
 * 覆盖下发频控、注册查重、校验失败计数与一次性消费等核心分支。
 *
 * @author hugray
 */
@ExtendWith(MockitoExtension.class)
class SmsCodeServiceTest
{
    private static final String PHONE = "13800000000";
 
    private static final String SCENE = "register";
 
    private static final String LOGIN_SCENE = "login";
 
    @Mock
    private RedisService redisService;
 
    @Mock
    private SmsSender smsSender;
 
    @Mock
    private RemoteAccountUserService remoteAccountUserService;
 
    @Mock
    private UniversalCodeProperties universalCodeProperties;
 
    @InjectMocks
    private SmsCodeService smsCodeService;
 
    /** 验证码缓存键:sms_codes:{scene}:{phone} */
    private String codeKey()
    {
        return CacheConstants.SMS_CODE_KEY + SCENE + ":" + PHONE;
    }
 
    /** 指定场景的验证码缓存键 */
    private String codeKey(String scene)
    {
        return CacheConstants.SMS_CODE_KEY + scene + ":" + PHONE;
    }
 
    /** 错误次数缓存键:sms_code_errs:{scene}:{phone} */
    private String errorKey()
    {
        return CacheConstants.SMS_CODE_ERR_KEY + SCENE + ":" + PHONE;
    }
 
    @Test
    @DisplayName("手机号格式非法时下发应被拒绝")
    void sendCode_shouldThrow_whenPhoneInvalid()
    {
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.sendCode("12345", SCENE));
        assertEquals("请输入正确手机号", ex.getMessage());
        verify(smsSender, never()).send(anyString(), anyString());
    }
 
    @Test
    @DisplayName("注册场景手机号已注册时应提示前往登录")
    void sendCode_shouldThrow_whenPhoneRegistered()
    {
        AccountMember member = new AccountMember();
        member.setPhone(PHONE);
        when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok(member));
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.sendCode(PHONE, SCENE));
        assertEquals("手机号已注册,请前往登录", ex.getMessage());
        verify(smsSender, never()).send(anyString(), anyString());
    }
 
    @Test
    @DisplayName("登录场景手机号未注册时应提示先注册")
    void sendCode_shouldThrow_whenPhoneNotRegisteredForLogin()
    {
        when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.sendCode(PHONE, LOGIN_SCENE));
        assertEquals("该手机号未注册,请先注册", ex.getMessage());
        verify(smsSender, never()).send(anyString(), anyString());
    }
 
    @Test
    @DisplayName("登录场景手机号已注册时应正常下发")
    void sendCode_shouldSend_whenLoginSceneAndRegistered()
    {
        AccountMember member = new AccountMember();
        member.setPhone(PHONE);
        when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok(member));
        when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(true);
 
        assertDoesNotThrow(() -> smsCodeService.sendCode(PHONE, LOGIN_SCENE));
 
        verify(redisService).setCacheObject(eq(codeKey(LOGIN_SCENE)), any(), any(), any(TimeUnit.class));
        verify(smsSender).send(eq(PHONE), anyString());
    }
 
    @Test
    @DisplayName("30 秒内重复下发应被频控拦截")
    void sendCode_shouldThrow_whenTooFrequent()
    {
        when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
        when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(false);
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.sendCode(PHONE, SCENE));
        assertEquals("验证码发送过于频繁,请稍后再试", ex.getMessage());
        verify(smsSender, never()).send(anyString(), anyString());
    }
 
    @Test
    @DisplayName("正常下发应写缓存并调用短信发送器")
    void sendCode_shouldSend_whenValid()
    {
        when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
        when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(true);
 
        assertDoesNotThrow(() -> smsCodeService.sendCode(PHONE, SCENE));
 
        verify(redisService).setCacheObject(eq(codeKey()), any(), any(), any(TimeUnit.class));
        verify(redisService).deleteObject(errorKey());
        verify(smsSender).send(eq(PHONE), anyString());
    }
 
    @Test
    @DisplayName("短信发送异常时应提示发送失败")
    void sendCode_shouldThrow_whenSmsSendFails()
    {
        when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
        when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(true);
        doThrow(new RuntimeException("gateway timeout")).when(smsSender).send(anyString(), anyString());
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.sendCode(PHONE, SCENE));
        assertEquals("短信发送失败", ex.getMessage());
    }
 
    @Test
    @DisplayName("验证码已失效时应提示重新获取")
    void verifyAndConsume_shouldThrow_whenCodeExpired()
    {
        when(redisService.getCacheObject(codeKey())).thenReturn(null);
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.verifyAndConsume(PHONE, SCENE, "123456"));
        assertEquals("验证码已失效,请重新获取", ex.getMessage());
    }
 
    @Test
    @DisplayName("验证码错误时应累加失败次数")
    void verifyAndConsume_shouldCountFailure_whenCodeMismatch()
    {
        when(redisService.getCacheObject(codeKey())).thenReturn("111111");
        when(redisService.getCacheObject(errorKey())).thenReturn(null);
        when(redisService.getExpire(codeKey())).thenReturn(300L);
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.verifyAndConsume(PHONE, SCENE, "222222"));
        assertEquals("验证码错误", ex.getMessage());
        verify(redisService).setCacheObject(eq(errorKey()), eq(1), eq(300L), eq(TimeUnit.SECONDS));
    }
 
    @Test
    @DisplayName("验证码错误次数达到上限应作废验证码")
    void verifyAndConsume_shouldVoidCode_whenTooManyFailures()
    {
        when(redisService.getCacheObject(codeKey())).thenReturn("111111");
        when(redisService.getCacheObject(errorKey())).thenReturn(4);
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.verifyAndConsume(PHONE, SCENE, "222222"));
        assertEquals("验证码错误次数过多,请重新获取", ex.getMessage());
        verify(redisService).deleteObject(codeKey());
        verify(redisService).deleteObject(errorKey());
    }
 
    @Test
    @DisplayName("验证码正确时应一次性消费")
    void verifyAndConsume_shouldConsume_whenCodeMatch()
    {
        when(redisService.getCacheObject(codeKey())).thenReturn("111111");
 
        assertDoesNotThrow(() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "111111"));
        verify(redisService).deleteObject(codeKey());
        verify(redisService).deleteObject(errorKey());
    }
 
    @Test
    @DisplayName("登录场景命中通用验证码应直接放行且不读缓存不消费")
    void verifyAndConsume_shouldPass_whenUniversalCodeHitForLogin()
    {
        when(universalCodeProperties.getUniversalSmsCode()).thenReturn("999999");
 
        assertDoesNotThrow(() -> smsCodeService.verifyAndConsume(PHONE, LOGIN_SCENE, "999999"));
        verify(redisService, never()).getCacheObject(anyString());
        verify(redisService, never()).deleteObject(anyString());
    }
 
    @Test
    @DisplayName("注册场景命中通用验证码应直接放行且不读缓存不消费")
    void verifyAndConsume_shouldPass_whenUniversalCodeHitForRegister()
    {
        when(universalCodeProperties.getUniversalSmsCode()).thenReturn("999999");
 
        assertDoesNotThrow(() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "999999"));
        verify(redisService, never()).getCacheObject(anyString());
        verify(redisService, never()).deleteObject(anyString());
    }
 
    @Test
    @DisplayName("非登录注册场景通用验证码不生效")
    void verifyAndConsume_shouldNotPass_whenUniversalCodeHitForResetScene()
    {
        when(universalCodeProperties.getUniversalSmsCode()).thenReturn("999999");
        when(redisService.getCacheObject(codeKey("reset"))).thenReturn(null);
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.verifyAndConsume(PHONE, "reset", "999999"));
        assertEquals("验证码已失效,请重新获取", ex.getMessage());
    }
 
    @Test
    @DisplayName("未配置通用验证码时不应放行")
    void verifyAndConsume_shouldNotPass_whenUniversalCodeNotConfigured()
    {
        when(universalCodeProperties.getUniversalSmsCode()).thenReturn(null);
        when(redisService.getCacheObject(codeKey())).thenReturn(null);
 
        ServiceException ex = assertThrows(ServiceException.class,
            () -> smsCodeService.verifyAndConsume(PHONE, SCENE, "999999"));
        assertEquals("验证码已失效,请重新获取", ex.getMessage());
    }
}