feat(wechat): 重写 code2Session 实现 5 步防御性处理

解决问题:
- 微信API返回 text/plain 时 JSONObject.parseObject 抛异常导致用户500
- 网络异常时 RestClientException 未被捕获
- 业务错误码(errcode != 0)未处理

实现 5 步防御:
1. 网络层:捕获 ResourceAccessException / RestClientException -> 502
2. HTTP 状态:手动检查 is2xxSuccessful(因 WechatResponseErrorHandler 让 4xx/5xx 不抛)-> 502
3. Content-Type:检查 isCompatibleWith(APPLICATION_JSON) -> 502
4. JSON 解析:try/catch 包裹 parseObject -> 500
5. 业务码:检查 errcode == 0 -> 400

TDD:先写 10 测试场景覆盖所有分支,再实现
- 正常 JSON 成功
- HTTP 4xx / 5xx
- Content-Type: text/plain / text/html
- JSON 解析失败
- 业务错误码 40029 / 40163
- 网络异常(ResourceAccessException)
- 响应缺少 session_key
This commit is contained in:
2026-06-03 23:03:44 +08:00
parent 012fa46d3a
commit 76d4d7465c
2 changed files with 334 additions and 0 deletions
@@ -0,0 +1,224 @@
package com.emotion.service.impl;
import com.emotion.config.WechatMiniProgramProperties;
import com.emotion.dto.wechat.WechatCodeSessionResponse;
import com.emotion.exception.WechatApiException;
import org.junit.jupiter.api.BeforeEach;
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 org.springframework.http.*;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* WechatMiniProgramServiceImpl.code2Session 单元测试
*
* <p>10 个测试场景覆盖 5 步防御性处理的所有分支:
* <ol>
* <li>正常 JSON 成功</li>
* <li>HTTP 4xx</li>
* <li>HTTP 5xx</li>
* <li>Content-Type: text/plain</li>
* <li>Content-Type: text/htmlWAF 拦截页)</li>
* <li>JSON 解析失败</li>
* <li>业务错误码 40029invalid code</li>
* <li>业务错误码 40163code been used</li>
* <li>网络异常(ResourceAccessException</li>
* <li>响应缺少 session_key</li>
* </ol>
*/
@ExtendWith(MockitoExtension.class)
class WechatMiniProgramServiceImplTest {
@Mock
private RestTemplate restTemplate;
@Mock
private WechatMiniProgramProperties properties;
@InjectMocks
private WechatMiniProgramServiceImpl service;
@BeforeEach
void setUp() {
when(properties.getAppId()).thenReturn("test-appid");
when(properties.getAppSecret()).thenReturn("test-secret");
when(properties.getBaseUrl()).thenReturn("https://api.weixin.qq.com");
}
@Test
@DisplayName("1. 正常 JSON 成功 - 返回 openid 和 session_key")
void code2Session_NormalJsonSuccess_ReturnsOpenIdAndSessionKey() {
String successBody = "{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> responseEntity = new ResponseEntity<>(successBody, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatCodeSessionResponse result = service.code2Session("validJsCode");
assertNotNull(result);
assertEquals("oXyz123", result.getOpenid());
assertEquals("abc456", result.getSessionKey());
verify(restTemplate, times(1)).getForEntity(anyString(), eq(String.class));
}
@Test
@DisplayName("2. HTTP 4xx - 抛 WechatApiException(502)")
void code2Session_Http4xx_ThrowsWechatApiException_502() {
ResponseEntity<String> responseEntity = new ResponseEntity<>("", HttpStatus.BAD_REQUEST);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
assertTrue(ex.getMessage().contains("非2xx") || ex.getMessage().contains("HTTP"));
}
@Test
@DisplayName("3. HTTP 5xx - 抛 WechatApiException(502)")
void code2Session_Http5xx_ThrowsWechatApiException_502() {
ResponseEntity<String> responseEntity = new ResponseEntity<>("", HttpStatus.INTERNAL_SERVER_ERROR);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
}
@Test
@DisplayName("4. Content-Type: text/plain - 抛 WechatApiException(502)")
void code2Session_ContentTypeTextPlain_ThrowsWechatApiException_502() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_PLAIN);
String body = "<html>WAF Challenge</html>";
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
assertTrue(ex.getMessage().contains("Content-Type") || ex.getMessage().contains("JSON"));
}
@Test
@DisplayName("5. Content-Type: text/htmlWAF 拦截页) - 抛 WechatApiException(502)")
void code2Session_ContentTypeTextHtml_WafChallenge_ThrowsWechatApiException_502() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
String body = "<html>WAF Challenge</html>";
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
}
@Test
@DisplayName("6. JSON 解析失败 - 抛 WechatApiException(500)")
void code2Session_JsonParseFailure_ThrowsWechatApiException_500() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "not valid json{";
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(500, ex.getCode());
assertTrue(ex.getMessage().contains("JSON"));
}
@Test
@DisplayName("7. 业务错误码 40029invalid code - 抛 WechatApiException(400)")
void code2Session_BusinessErrorCode40029_InvalidCode_ThrowsWechatApiException_400() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{\"errcode\":40029,\"errmsg\":\"invalid code\"}";
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("invalidCode"));
assertEquals(400, ex.getCode());
assertTrue(ex.getMessage().contains("40029") || ex.getMessage().contains("invalid"));
}
@Test
@DisplayName("8. 业务错误码 40163code been used - 抛 WechatApiException(400)")
void code2Session_BusinessErrorCode40163_CodeUsed_ThrowsWechatApiException_400() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{\"errcode\":40163,\"errmsg\":\"code been used\"}";
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("usedCode"));
assertEquals(400, ex.getCode());
}
@Test
@DisplayName("9. 网络异常(ResourceAccessException - 抛 WechatApiException(502)")
void code2Session_NetworkException_ResourceAccessException_ThrowsWechatApiException_502() {
when(restTemplate.getForEntity(anyString(), eq(String.class)))
.thenThrow(new ResourceAccessException("Connection refused"));
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
assertTrue(ex.getMessage().contains("网络") || ex.getMessage().contains("network"));
}
@Test
@DisplayName("10. 响应缺少 session_key - 抛 WechatApiException(502)")
void code2Session_MissingSessionKey_ThrowsWechatApiException_502() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{\"errcode\":0,\"openid\":\"oXyz123\"}";
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
assertTrue(ex.getMessage().contains("session_key"));
}
@Test
@DisplayName("11. 其他 RestClientException(非 ResourceAccessException- 抛 WechatApiException(502)")
void code2Session_OtherRestClientException_ThrowsWechatApiException_502() {
when(restTemplate.getForEntity(anyString(), eq(String.class)))
.thenThrow(new HttpClientErrorException(HttpStatus.FORBIDDEN, "Forbidden", null, null, null));
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
assertTrue(ex.getMessage().contains("RestClient") || ex.getMessage().contains("客户端"));
}
@Test
@DisplayName("12. 响应缺少 openid(有 session_key- 抛 WechatApiException(502)")
void code2Session_MissingOpenId_ThrowsWechatApiException_502() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{\"errcode\":0,\"session_key\":\"abc456\"}"; // no openid
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
WechatApiException ex = assertThrows(WechatApiException.class,
() -> service.code2Session("code"));
assertEquals(502, ex.getCode());
assertTrue(ex.getMessage().contains("openid") || ex.getMessage().contains("session_key"));
}
}