1 changed files with 451 additions and 0 deletions
@ -0,0 +1,451 @@ |
|||||
|
package com.mydreamjob.skill; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.JwtTokenProvider; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.profile.StudentProfile; |
||||
|
import com.mydreamjob.profile.StudentProfileRepository; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.data.redis.core.ValueOperations; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.TestPropertySource; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Locale; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* T9: real HTTP -> Security -> Controller -> Service -> Repository -> Flyway/H2 regression. |
||||
|
* |
||||
|
* <p>The test deliberately uses the real V36 data and real persisted student profile. It does |
||||
|
* not mock the skill services, so it can catch inconsistent TOP10 derivation, accidental writes, |
||||
|
* and response-layer privacy leaks.</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
@TestPropertySource(properties = { |
||||
|
"spring.datasource.url=jdbc:h2:mem:skill_cloud_t9_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", |
||||
|
"spring.jpa.show-sql=false" |
||||
|
}) |
||||
|
@org.springframework.transaction.annotation.Transactional |
||||
|
class SkillCloudEndToEndIntegrationTest { |
||||
|
|
||||
|
private static final String WORD_CLOUD_PATH = "/skills/word-cloud"; |
||||
|
private static final String MATCH_PATH = "/skills/match"; |
||||
|
private static final String LEARNING_PATH = "/skills/learning-path"; |
||||
|
|
||||
|
private static final long BACKEND_INDUSTRY_ID = 1L; |
||||
|
private static final long BACKEND_OCCUPATION_ID = 2L; |
||||
|
private static final long THREE_SKILL_INDUSTRY_ID = 3L; |
||||
|
private static final long THREE_SKILL_OCCUPATION_ID = 9L; |
||||
|
private static final long THREE_SKILL_CATEGORY_ID = 903L; |
||||
|
private static final long EMPTY_CATEGORY_ID = 999_001L; |
||||
|
|
||||
|
private static final AtomicLong PHONE = new AtomicLong(940_000_000L); |
||||
|
|
||||
|
@Autowired |
||||
|
private MockMvc mockMvc; |
||||
|
|
||||
|
@Autowired |
||||
|
private ObjectMapper objectMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@Autowired |
||||
|
private JwtTokenProvider jwtTokenProvider; |
||||
|
|
||||
|
@Autowired |
||||
|
private UserRepository userRepository; |
||||
|
|
||||
|
@Autowired |
||||
|
private StudentProfileRepository studentProfileRepository; |
||||
|
|
||||
|
@MockBean |
||||
|
private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@BeforeEach |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void setUpRedisMock() { |
||||
|
ValueOperations<String, Object> operations = mock(ValueOperations.class); |
||||
|
when(redisTemplate.opsForValue()).thenReturn(operations); |
||||
|
when(redisTemplate.hasKey(any())).thenReturn(false); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void threeEndpointsShareTop10AndDoNotWriteForBackendDeveloper() throws Exception { |
||||
|
StudentFixture student = createStudent("[\"Django\",\"Git\",\"Java\"]"); |
||||
|
Map<String, Long> countsBefore = taxonomyAndProfileCounts(); |
||||
|
|
||||
|
JsonNode cloud = data(getOkBody( |
||||
|
WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); |
||||
|
List<JsonNode> allSkills = nodes(cloud.path("items")); |
||||
|
assertThat(cloud.path("total").asInt()).isEqualTo(16); |
||||
|
assertThat(allSkills).hasSize(16); |
||||
|
assertRanksAreContinuous(allSkills); |
||||
|
assertHeatOrder(allSkills); |
||||
|
|
||||
|
List<JsonNode> expectedTop10 = allSkills.subList(0, Math.min(10, allSkills.size())); |
||||
|
|
||||
|
JsonNode match = data(getOkBody( |
||||
|
MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); |
||||
|
assertMatchReusesTop10(match, expectedTop10); |
||||
|
|
||||
|
JsonNode learningPath = data(getOkBody( |
||||
|
LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); |
||||
|
assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills")); |
||||
|
|
||||
|
JsonNode cloudAgain = data(getOkBody( |
||||
|
WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); |
||||
|
JsonNode matchAgain = data(getOkBody( |
||||
|
MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); |
||||
|
JsonNode learningAgain = data(getOkBody( |
||||
|
LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); |
||||
|
|
||||
|
assertThat(cloudAgain).isEqualTo(cloud); |
||||
|
assertThat(matchAgain).isEqualTo(match); |
||||
|
assertThat(learningAgain).isEqualTo(learningPath); |
||||
|
assertThat(taxonomyAndProfileCounts()).isEqualTo(countsBefore); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void matchScoreUsesActualTopSizeAndRoundsToOneThird() throws Exception { |
||||
|
StudentFixture student = createStudent("[\"Articulate Storyline\"]"); |
||||
|
|
||||
|
JsonNode cloud = data(getOkBody( |
||||
|
WORD_CLOUD_PATH, |
||||
|
THREE_SKILL_INDUSTRY_ID, |
||||
|
THREE_SKILL_OCCUPATION_ID, |
||||
|
THREE_SKILL_CATEGORY_ID, |
||||
|
student.authorization(), |
||||
|
null)); |
||||
|
List<JsonNode> skills = nodes(cloud.path("items")); |
||||
|
assertThat(cloud.path("total").asInt()).isEqualTo(3); |
||||
|
assertThat(skills).hasSize(3); |
||||
|
assertHeatOrder(skills); |
||||
|
JsonNode lastHeat = skills.get(skills.size() - 1).path("heat"); |
||||
|
assertThat(lastHeat.isMissingNode() || lastHeat.isNull()).isTrue(); |
||||
|
|
||||
|
JsonNode match = data(getOkBody( |
||||
|
MATCH_PATH, |
||||
|
THREE_SKILL_INDUSTRY_ID, |
||||
|
THREE_SKILL_OCCUPATION_ID, |
||||
|
THREE_SKILL_CATEGORY_ID, |
||||
|
student.authorization(), |
||||
|
null)); |
||||
|
assertThat(match.path("totalCount").asInt()).isEqualTo(3); |
||||
|
assertThat(match.path("matchedCount").asInt()).isEqualTo(1); |
||||
|
assertThat(match.path("matchScore").asInt()).isEqualTo(33); |
||||
|
assertThat(match.path("matchedSkills")).hasSize(1); |
||||
|
assertThat(match.path("missingSkills")).hasSize(2); |
||||
|
assertThat(ids(match.path("matchedSkills"))) |
||||
|
.containsExactly(skills.get(0).path("skillId").asLong()); |
||||
|
|
||||
|
JsonNode learningPath = data(getOkBody( |
||||
|
LEARNING_PATH, |
||||
|
THREE_SKILL_INDUSTRY_ID, |
||||
|
THREE_SKILL_OCCUPATION_ID, |
||||
|
THREE_SKILL_CATEGORY_ID, |
||||
|
student.authorization(), |
||||
|
null)); |
||||
|
assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void emptyCategoryReturnsEmptyResponsesAtPersistenceBoundary() throws Exception { |
||||
|
StudentFixture student = createStudent("[\"Java\"]"); |
||||
|
jdbcTemplate.update( |
||||
|
"INSERT INTO skill_categories " |
||||
|
+ "(id, occupation_id, code, name_zh, suggestion_template_code, sort_order, created_at, updated_at) " |
||||
|
+ "VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", |
||||
|
EMPTY_CATEGORY_ID, |
||||
|
BACKEND_OCCUPATION_ID, |
||||
|
"T9_EMPTY_CATEGORY", |
||||
|
"T9 空分类", |
||||
|
"GENERAL", |
||||
|
99); |
||||
|
|
||||
|
JsonNode cloud = data(getOkBody( |
||||
|
WORD_CLOUD_PATH, |
||||
|
BACKEND_INDUSTRY_ID, |
||||
|
BACKEND_OCCUPATION_ID, |
||||
|
EMPTY_CATEGORY_ID, |
||||
|
student.authorization(), |
||||
|
null)); |
||||
|
assertThat(cloud.path("total").asInt()).isZero(); |
||||
|
assertThat(cloud.path("items")).isEmpty(); |
||||
|
|
||||
|
JsonNode match = data(getOkBody( |
||||
|
MATCH_PATH, |
||||
|
BACKEND_INDUSTRY_ID, |
||||
|
BACKEND_OCCUPATION_ID, |
||||
|
EMPTY_CATEGORY_ID, |
||||
|
student.authorization(), |
||||
|
null)); |
||||
|
assertThat(match.path("matchScore").asInt()).isZero(); |
||||
|
assertThat(match.path("matchedCount").asInt()).isZero(); |
||||
|
assertThat(match.path("totalCount").asInt()).isZero(); |
||||
|
assertThat(match.path("matchedSkills")).isEmpty(); |
||||
|
assertThat(match.path("missingSkills")).isEmpty(); |
||||
|
|
||||
|
JsonNode learningPath = data(getOkBody( |
||||
|
LEARNING_PATH, |
||||
|
BACKEND_INDUSTRY_ID, |
||||
|
BACKEND_OCCUPATION_ID, |
||||
|
EMPTY_CATEGORY_ID, |
||||
|
student.authorization(), |
||||
|
null)); |
||||
|
assertThat(learningPath.path("total").asInt()).isZero(); |
||||
|
assertThat(learningPath.path("items")).isEmpty(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void identityQueryParametersCannotOverrideAuthenticatedStudent() throws Exception { |
||||
|
StudentFixture student = createStudent("[\"Java\"]"); |
||||
|
User company = createUser(User.Role.COMPANY); |
||||
|
|
||||
|
for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) { |
||||
|
JsonNode authenticated = data(getOkBody( |
||||
|
path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); |
||||
|
JsonNode withOverrides = data(getOkBody( |
||||
|
path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), company.getId())); |
||||
|
|
||||
|
assertThat(withOverrides).isEqualTo(authenticated); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void responsesDoNotLeakPrivateProfileDataOrInternalErrors() throws Exception { |
||||
|
StudentFixture student = createStudent("[\"Java\"]"); |
||||
|
|
||||
|
for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) { |
||||
|
String body = getOkBody( |
||||
|
path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null); |
||||
|
assertNoPrivateData(body, student); |
||||
|
} |
||||
|
|
||||
|
String errorBody = mockMvc.perform(get(WORD_CLOUD_PATH) |
||||
|
.param("industryId", String.valueOf(BACKEND_INDUSTRY_ID)) |
||||
|
.param("occupationId", String.valueOf(BACKEND_OCCUPATION_ID)) |
||||
|
.param("categoryId", "999999") |
||||
|
.header("Authorization", student.authorization())) |
||||
|
.andExpect(status().isBadRequest()) |
||||
|
.andExpect(jsonPath("$.code").isNumber()) |
||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
||||
|
|
||||
|
assertThat(errorBody.toLowerCase(Locale.ROOT)) |
||||
|
.doesNotContain("exception") |
||||
|
.doesNotContain("org.springframework") |
||||
|
.doesNotContain("java.sql") |
||||
|
.doesNotContain("stacktrace") |
||||
|
.doesNotContain("\tat "); |
||||
|
} |
||||
|
|
||||
|
private void assertMatchReusesTop10(JsonNode match, List<JsonNode> expectedTop10) { |
||||
|
List<Long> expectedIds = ids(expectedTop10); |
||||
|
List<Long> matchedIds = ids(match.path("matchedSkills")); |
||||
|
List<Long> missingIds = ids(match.path("missingSkills")); |
||||
|
|
||||
|
assertThat(match.path("totalCount").asInt()).isEqualTo(expectedTop10.size()); |
||||
|
assertThat(match.path("matchedCount").asInt()).isEqualTo(matchedIds.size()); |
||||
|
assertThat(matchedIds).allMatch(expectedIds::contains); |
||||
|
assertThat(expectedIds).containsAll(matchedIds); |
||||
|
|
||||
|
List<Long> expectedMatched = expectedIds.stream().filter(matchedIds::contains).toList(); |
||||
|
List<Long> expectedMissing = expectedIds.stream().filter(id -> !matchedIds.contains(id)).toList(); |
||||
|
assertThat(matchedIds).containsExactlyElementsOf(expectedMatched); |
||||
|
assertThat(missingIds).containsExactlyElementsOf(expectedMissing); |
||||
|
|
||||
|
List<Long> union = new ArrayList<>(matchedIds); |
||||
|
union.addAll(missingIds); |
||||
|
assertThat(union).containsExactlyInAnyOrderElementsOf(expectedIds); |
||||
|
int expectedScore = expectedTop10.isEmpty() |
||||
|
? 0 |
||||
|
: (int) Math.round(matchedIds.size() * 100.0 / expectedTop10.size()); |
||||
|
assertThat(match.path("matchScore").asInt()).isEqualTo(expectedScore); |
||||
|
} |
||||
|
|
||||
|
private void assertLearningPathFollowsMissingSkills(JsonNode learningPath, JsonNode missingSkills) { |
||||
|
assertThat(learningPath.path("total").asInt()).isEqualTo(missingSkills.size()); |
||||
|
assertThat(learningPath.path("items")).hasSize(missingSkills.size()); |
||||
|
for (int index = 0; index < missingSkills.size(); index++) { |
||||
|
JsonNode item = learningPath.path("items").get(index); |
||||
|
JsonNode missing = missingSkills.get(index); |
||||
|
assertThat(item.path("priority").asInt()).isEqualTo(index + 1); |
||||
|
assertThat(item.path("skillId").asLong()).isEqualTo(missing.path("skillId").asLong()); |
||||
|
assertThat(item.path("skillName").asText()).isEqualTo(missing.path("nameZh").asText()); |
||||
|
assertThat(item.path("suggestion").asText()).isNotBlank(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void assertRanksAreContinuous(List<JsonNode> items) { |
||||
|
for (int index = 0; index < items.size(); index++) { |
||||
|
assertThat(items.get(index).path("rank").asInt()).isEqualTo(index + 1); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void assertHeatOrder(List<JsonNode> items) { |
||||
|
boolean seenNullHeat = false; |
||||
|
BigDecimal previousHeat = null; |
||||
|
for (JsonNode item : items) { |
||||
|
if (item.path("heat").isNull()) { |
||||
|
seenNullHeat = true; |
||||
|
continue; |
||||
|
} |
||||
|
assertThat(seenNullHeat).isFalse(); |
||||
|
BigDecimal currentHeat = item.path("heat").decimalValue(); |
||||
|
if (previousHeat != null) { |
||||
|
assertThat(currentHeat).isLessThanOrEqualTo(previousHeat); |
||||
|
} |
||||
|
previousHeat = currentHeat; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void assertNoPrivateData(String body, StudentFixture student) { |
||||
|
assertThat(body).doesNotContain("skills_json"); |
||||
|
assertThat(body).doesNotContain(student.phone()); |
||||
|
assertThat(body).doesNotContain(student.email()); |
||||
|
assertThat(body).doesNotContain(student.resumeUrl()); |
||||
|
assertThat(body).doesNotContain(student.resumeOriginalName()); |
||||
|
assertThat(body).doesNotContain(student.accessToken()); |
||||
|
} |
||||
|
|
||||
|
private String getOkBody(String path, |
||||
|
long industryId, |
||||
|
long occupationId, |
||||
|
Long categoryId, |
||||
|
String authorization, |
||||
|
Long overrideIdentity) throws Exception { |
||||
|
var request = get(path) |
||||
|
.param("industryId", String.valueOf(industryId)) |
||||
|
.param("occupationId", String.valueOf(occupationId)) |
||||
|
.header("Authorization", authorization); |
||||
|
if (categoryId != null) { |
||||
|
request.param("categoryId", String.valueOf(categoryId)); |
||||
|
} |
||||
|
if (overrideIdentity != null) { |
||||
|
request.param("userId", String.valueOf(overrideIdentity)); |
||||
|
request.param("studentId", String.valueOf(overrideIdentity)); |
||||
|
} |
||||
|
|
||||
|
return mockMvc.perform(request) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.code").value(200)) |
||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
||||
|
} |
||||
|
|
||||
|
private JsonNode data(String body) throws Exception { |
||||
|
return objectMapper.readTree(body).path("data"); |
||||
|
} |
||||
|
|
||||
|
private List<JsonNode> nodes(JsonNode array) { |
||||
|
List<JsonNode> nodes = new ArrayList<>(); |
||||
|
array.forEach(nodes::add); |
||||
|
return nodes; |
||||
|
} |
||||
|
|
||||
|
private List<Long> ids(JsonNode array) { |
||||
|
return ids(nodes(array)); |
||||
|
} |
||||
|
|
||||
|
private List<Long> ids(List<JsonNode> items) { |
||||
|
return items.stream().map(node -> node.path("skillId").asLong()).toList(); |
||||
|
} |
||||
|
|
||||
|
private StudentFixture createStudent(String skillsJson) { |
||||
|
long phoneNumber = PHONE.getAndIncrement(); |
||||
|
String phone = String.valueOf(phoneNumber); |
||||
|
String email = "private.student." + phoneNumber + "@example.com"; |
||||
|
String resumeUrl = "https://private.example/" + phoneNumber + "-resume.pdf"; |
||||
|
String resumeOriginalName = "private-" + phoneNumber + "-resume.pdf"; |
||||
|
|
||||
|
User user = userRepository.saveAndFlush(User.builder() |
||||
|
.phone(phone) |
||||
|
.email(email) |
||||
|
.role(User.Role.STUDENT) |
||||
|
.roles(Set.of(User.Role.STUDENT)) |
||||
|
.active(true) |
||||
|
.verified(true) |
||||
|
.build()); |
||||
|
studentProfileRepository.saveAndFlush(StudentProfile.builder() |
||||
|
.userId(user.getId()) |
||||
|
.realName("sensitive-real-name") |
||||
|
.bio("raw-resume-sentinel") |
||||
|
.resumeUrl(resumeUrl) |
||||
|
.resumeOriginalName(resumeOriginalName) |
||||
|
.skillsJson(skillsJson) |
||||
|
.build()); |
||||
|
|
||||
|
String accessToken = jwtTokenProvider.generateAccessToken( |
||||
|
user.getId(), |
||||
|
phone, |
||||
|
Map.of("roles", "STUDENT")); |
||||
|
return new StudentFixture(user.getId(), phone, email, resumeUrl, resumeOriginalName, accessToken); |
||||
|
} |
||||
|
|
||||
|
private User createUser(User.Role role) { |
||||
|
long phoneNumber = PHONE.getAndIncrement(); |
||||
|
return userRepository.saveAndFlush(User.builder() |
||||
|
.phone(String.valueOf(phoneNumber)) |
||||
|
.role(role) |
||||
|
.roles(Set.of(role)) |
||||
|
.active(true) |
||||
|
.verified(true) |
||||
|
.build()); |
||||
|
} |
||||
|
|
||||
|
private Map<String, Long> taxonomyAndProfileCounts() { |
||||
|
Map<String, Long> counts = new LinkedHashMap<>(); |
||||
|
for (String table : List.of( |
||||
|
"skill_source_versions", |
||||
|
"skill_industries", |
||||
|
"skill_occupations", |
||||
|
"skill_categories", |
||||
|
"skills", |
||||
|
"skill_aliases", |
||||
|
"occupation_skills", |
||||
|
"student_profiles")) { |
||||
|
Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); |
||||
|
counts.put(table, count); |
||||
|
} |
||||
|
return counts; |
||||
|
} |
||||
|
|
||||
|
private record StudentFixture( |
||||
|
Long userId, |
||||
|
String phone, |
||||
|
String email, |
||||
|
String resumeUrl, |
||||
|
String resumeOriginalName, |
||||
|
String accessToken |
||||
|
) { |
||||
|
String authorization() { |
||||
|
return "Bearer " + accessToken; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue