You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

473 lines
35 KiB

package com.mydreamjob.onlineresume;
import com.fasterxml.jackson.databind.*;
import com.mydreamjob.auth.*;
import com.mydreamjob.profile.*;
import com.mydreamjob.preference.*;
import org.junit.jupiter.api.*;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.*;
import org.springframework.http.MediaType;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.ZipInputStream;
import java.io.ByteArrayInputStream;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test")
@org.springframework.test.context.TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:online_resume;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class OnlineResumeIntegrationTest {
private static final String PATH = "/profile/me/online-resume";
private static final AtomicLong PHONE = new AtomicLong(19907000000L);
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired UserRepository users;
@Autowired AuthSessionService sessions;
@Autowired StudentProfileRepository profiles;
@Autowired StudentPreferenceRepository preferences;
@Autowired OnlineResumeRepository resumes;
@Autowired OnlineResumeService service;
@Autowired ProfileService profileService;
@Autowired org.springframework.transaction.PlatformTransactionManager transactionManager;
@Autowired com.mydreamjob.dataexport.PersonalDataCollector collector;
@Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions;
@Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker;
@MockBean RedisTemplate<String, Object> redis;
User user; String token;
@BeforeEach void setup() {
user = createUser(User.Role.STUDENT); token = bearer(user);
}
User createUser(User.Role role) {
return users.save(User.builder().phone(Long.toString(PHONE.incrementAndGet())).email("synthetic@example.test")
.role(role).roles(Set.of(role)).active(true).build());
}
String bearer(User u) {
return "Bearer " + sessions.createSession(u, java.time.Instant.now(), new com.mydreamjob.audit.AuditContext()).accessToken();
}
String legacySkillFixture() { return "[Java, SQL]"; }
JsonNode data(ResultActions action) throws Exception {
return mapper.readTree(action.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data");
}
JsonNode read() throws Exception { return data(mvc.perform(get(PATH).header("Authorization", token))); }
OnlineResumeContent content() {
OnlineResumeContent c = new OnlineResumeContent(); c.getBasicInfo().setRealName("合成测试");
c.getBasicInfo().setPhone("13800000000"); c.getBasicInfo().setEmail("resume@example.test");
c.setSelfEvaluation("合成自我评价 <>&"); c.getJobIntention().setTargetPosition("后端工程师");
c.getJobIntention().setJobSeekingStatus(OnlineResumeContent.JobSeekingStatus.LOOKING);
c.getJobIntention().setEmploymentStatus(OnlineResumeContent.EmploymentStatus.UNEMPLOYED);
c.getJobIntention().setAvailableFrom(LocalDate.of(2026,11,1));
c.getJobIntention().setExpectedSalaryMin(10000); c.getJobIntention().setExpectedSalaryMax(20000);
c.getJobIntention().setPreferredCities(List.of("测试城市"));
var e = new OnlineResumeContent.Education(); e.setSchool("合成大学");e.setDegree("本科");e.setMajor("计算机");
e.setStartDate(LocalDate.of(2018,9,1));e.setEndDate(LocalDate.of(2022,6,1));c.getEducationExperiences().add(e);
var w = new OnlineResumeContent.Work();w.setCompany("合成企业");w.setPosition("工程师");w.setStartDate(LocalDate.of(2022,7,1));w.setOngoing(true);c.getWorkExperiences().add(w);
var p = new OnlineResumeContent.Project();p.setName("合成项目");p.setRole("开发");p.setStartDate(LocalDate.of(2023,1,1));p.setOngoing(true);c.getProjectExperiences().add(p);
var s = new OnlineResumeContent.Skill();s.setName("Java");s.setLevel(OnlineResumeContent.SkillLevel.PROFICIENT);s.setYears(3);c.getSkills().add(s);
return c;
}
ResultActions save(long revision, OnlineResumeContent c) throws Exception {
return mvc.perform(put(PATH).header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(new OnlineResumeSaveRequest(revision,c))));
}
@Test void emptyReadDoesNotCreateRowsAndDefaultsContacts() throws Exception {
JsonNode d=read();assertEquals(0,d.path("revision").asLong());
assertEquals(user.getPhone(),d.at("/content/basicInfo/phone").asText());
assertEquals(user.getEmail(),d.at("/content/basicInfo/email").asText());
assertFalse(resumes.existsById(user.getId()));assertFalse(profiles.existsByUserId(user.getId()));
}
@Test void savesAllSectionsAndFreshSessionReadsPersistedState() throws Exception {
JsonNode saved=data(save(0,content()));assertEquals(1,saved.path("revision").asLong());
for(String s:List.of("educationExperiences","workExperiences","projectExperiences","skills")) assertFalse(saved.at("/content/"+s+"/0/id").asText().isBlank());
token=bearer(user);assertEquals(saved,read());
assertEquals(user.getPhone(),users.findById(user.getId()).orElseThrow().getPhone());
assertEquals(user.getEmail(),users.findById(user.getId()).orElseThrow().getEmail());
assertEquals("合成测试",profiles.findByUserId(user.getId()).orElseThrow().getRealName());
JsonNode legacySkills=mapper.readTree(profiles.findByUserId(user.getId()).orElseThrow().getSkillsJson());
if(legacySkills.isTextual())legacySkills=mapper.readTree(legacySkills.asText());
assertEquals(mapper.readTree("[\"Java\"]"),legacySkills);
assertEquals(10000,preferences.findByStudentUserId(user.getId()).orElseThrow().getExpectedSalaryMin());
}
@Test void existingProfilePreferencesAndAttachmentAreIncludedAndPreserved() throws Exception {
profiles.save(StudentProfile.builder().userId(user.getId()).realName("既有姓名").bio("既有简介").skillsJson(legacySkillFixture())
.resumeUrl(user.getId()+"_abcdef01.docx").resumeOriginalName("合成.docx").resumeContentType("application/test").resumeSize(123L).build());
preferences.save(StudentPreference.builder().studentUserId(user.getId()).preferredCities("[\"测试\"]").remoteAllowed(true).build());
JsonNode d=read();assertEquals(2,d.at("/content/skills").size());assertEquals("既有简介",d.at("/content/selfEvaluation").asText());
assertEquals("合成.docx",d.at("/attachment/fileName").asText());assertEquals(123,d.at("/attachment/size").asInt());
assertTrue(d.at("/content/jobIntention/remoteAllowed").asBoolean());
data(save(0,content()));assertEquals(user.getId()+"_abcdef01.docx",profiles.findByUserId(user.getId()).orElseThrow().getResumeUrl());
}
@Test void staleSaveIsConflictAndLeavesAllSectionsUntouched() throws Exception {
JsonNode first=data(save(0,content()));var stale=content();stale.getBasicInfo().setRealName("不能覆盖");
save(0,stale).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002));assertEquals(first,read());
}
@Test void fullReplacementCanClearListsContactsAndLegacyFields() throws Exception {
data(save(0,content()));JsonNode d=data(save(1,new OnlineResumeContent()));
assertEquals(0,d.at("/content/workExperiences").size());assertTrue(d.at("/content/basicInfo/phone").isMissingNode()||d.at("/content/basicInfo/phone").isNull());
assertNull(profiles.findByUserId(user.getId()).orElseThrow().getRealName());
assertNull(preferences.findByStudentUserId(user.getId()).orElseThrow().getExpectedSalaryMin());
}
@Test void educationWorkProjectSkillCrudUsesSharedRevision() throws Exception {
var c=content();long revision=0;
for(var item:Map.of("education-experiences",c.getEducationExperiences().get(0),"work-experiences",c.getWorkExperiences().get(0),
"project-experiences",c.getProjectExperiences().get(0),"skills",c.getSkills().get(0)).entrySet()) {
String path=PATH+"/"+item.getKey();
JsonNode added=data(mvc.perform(post(path).header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(Map.of("revision",revision,"entry",item.getValue())))));
revision=added.path("revision").asLong();
JsonNode list=data(mvc.perform(get(path).header("Authorization",token)));String id=list.get(0).path("id").asText();assertFalse(id.isBlank());
assertEquals(list.get(0),data(mvc.perform(get(path+"/"+id).header("Authorization",token))));
JsonNode updated=data(mvc.perform(put(path+"/"+id).header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(Map.of("revision",revision,"entry",item.getValue())))));revision=updated.path("revision").asLong();
data(mvc.perform(delete(path+"/"+id).param("revision",Long.toString(revision)).header("Authorization",token)));revision++;
mvc.perform(get(path+"/"+id).header("Authorization",token)).andExpect(status().isNotFound());
}
assertEquals(12,read().path("revision").asLong());
}
@Test void authAndOtherAccountIsolation() throws Exception {
mvc.perform(get(PATH)).andExpect(status().isUnauthorized());
for(var role:List.of(User.Role.COMPANY,User.Role.ADMIN)) mvc.perform(get(PATH).header("Authorization",bearer(createUser(role)))).andExpect(status().isForbidden());
data(save(0,content()));User other=createUser(User.Role.STUDENT);
JsonNode d=data(mvc.perform(get(PATH).param("userId",user.getId().toString()).header("Authorization",bearer(other))));
assertEquals(0,d.path("revision").asLong());assertEquals(other.getPhone(),d.at("/content/basicInfo/phone").asText());
assertEquals(0,d.at("/content/workExperiences").size());
String id=read().at("/content/workExperiences/0/id").asText();
mvc.perform(delete(PATH+"/work-experiences/"+id).param("revision","0").header("Authorization",bearer(other))).andExpect(status().isNotFound());
assertEquals(1,read().at("/content/workExperiences").size());
}
@Test void invalidDatesContactsSalaryAndNullEntriesAreRejectedAtomically() throws Exception {
var c=content();c.getBasicInfo().setEmail("invalid");save(0,c).andExpect(status().isBadRequest());
c=content();c.getBasicInfo().setPhone("abc");save(0,c).andExpect(status().isBadRequest());
c=content();c.getJobIntention().setExpectedSalaryMin(30000);save(0,c).andExpect(status().isBadRequest());
c=content();c.getEducationExperiences().get(0).setEndDate(LocalDate.of(2010,1,1));save(0,c).andExpect(status().isBadRequest());
c=content();c.getWorkExperiences().get(0).setEndDate(LocalDate.now());save(0,c).andExpect(status().isBadRequest());
c=content();c.getSkills().add(null);save(0,c).andExpect(status().isBadRequest());
assertFalse(resumes.existsById(user.getId()));assertFalse(profiles.existsByUserId(user.getId()));
}
@Test void unknownAndDuplicateIdsRejectedWithoutLosingExistingData() throws Exception {
var c=content();c.getWorkExperiences().get(0).setId(UUID.randomUUID().toString());save(0,c).andExpect(status().isBadRequest());
var saved=data(save(0,content()));c=mapper.treeToValue(saved.path("content"),OnlineResumeContent.class);
c.getWorkExperiences().add(c.getWorkExperiences().get(0));save(1,c).andExpect(status().isBadRequest());assertEquals(saved,read());
}
@Test void listLimitsAndXmlControlCharactersAreRejected() throws Exception {
var c=content();for(int i=0;i<31;i++)c.getWorkExperiences().add(c.getWorkExperiences().get(0));save(0,c).andExpect(status().isBadRequest());
c=content();c.setSelfEvaluation("bad\u0000text");save(0,c).andExpect(status().isBadRequest());
c=content();c.getSkills().add(c.getSkills().get(0));save(0,c).andExpect(status().isBadRequest());
assertFalse(resumes.existsById(user.getId()));
}
@Test void downloadIsValidEscapedDocxAndNeverReadsAttachment() throws Exception {
data(save(0,content()));byte[] bytes=mvc.perform(get(PATH+"/download").header("Authorization",token)).andExpect(status().isOk())
.andExpect(header().string("Cache-Control","no-store")).andReturn().getResponse().getContentAsByteArray();
Map<String,String> files=new HashMap<>();try(var zip=new ZipInputStream(new ByteArrayInputStream(bytes),StandardCharsets.UTF_8)){
java.util.zip.ZipEntry e;while((e=zip.getNextEntry())!=null)files.put(e.getName(),new String(zip.readAllBytes(),StandardCharsets.UTF_8));}
assertTrue(files.containsKey("[Content_Types].xml"));assertTrue(files.containsKey("_rels/.rels"));
String xml=files.get("word/document.xml");assertTrue(xml.contains("合成企业"));assertTrue(xml.contains("&lt;&gt;&amp;"));
var factory=javax.xml.parsers.DocumentBuilderFactory.newInstance();factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",true);
factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
String destination=System.getProperty("qsp.onlineResume.docxOutput");
if(destination!=null)java.nio.file.Files.write(java.nio.file.Path.of(destination),bytes);
}
@Test void concurrentFirstSaveHasOneWinner() throws Exception {
var pool=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1);
try {
List<Future<Boolean>> futures=new ArrayList<>();
for(int i=0;i<2;i++) futures.add(pool.submit(()->{assertTrue(start.await(5,TimeUnit.SECONDS));
try{service.save(user.getId(),new OnlineResumeSaveRequest(0L,content()));return true;}
catch(com.mydreamjob.common.exception.BusinessException e){assertEquals(6002,e.getErrorCodeInt());return false;}}));
start.countDown();int winners=0;for(var f:futures)if(f.get(30,TimeUnit.SECONDS))winners++;
assertEquals(1,winners);assertEquals(1,read().path("revision").asLong());
}finally{pool.shutdownNow();}
}
@Test void personalDataExportIncludesOnlyOwnOnlineResume() throws Exception {
data(save(0,content()));User other=createUser(User.Role.STUDENT);
var otherContent=content();otherContent.getBasicInfo().setRealName("其他合成账号");
service.save(other.getId(),new OnlineResumeSaveRequest(0L,otherContent));
var exported=collector.collect(user.getId()).getSections();
assertTrue(exported.containsKey("online-resume"));
String serialized=mapper.writeValueAsString(exported.get("online-resume"));
assertTrue(serialized.contains("合成测试"));assertFalse(serialized.contains("其他合成账号"));
}
@Test void accountDeletionPurgesOnlyOwnOnlineResume() throws Exception {
data(save(0,content()));User other=createUser(User.Role.STUDENT);
service.save(other.getId(),new OnlineResumeSaveRequest(0L,content()));
var past=java.time.LocalDateTime.now().minusDays(1);
deletions.save(com.mydreamjob.deletion.AccountDeletion.builder().userId(user.getId())
.status(com.mydreamjob.deletion.DeletionStatus.PENDING).requestedAt(past.minusDays(30)).effectiveAt(past).version(0L).build());
assertTrue(deletionWorker.completeIfExpired(user.getId()));
assertFalse(resumes.existsById(user.getId()));assertTrue(resumes.existsById(other.getId()));
}
@Test void legacyProfileUpdatesAreReflectedAndSkillDetailsAreRetained() throws Exception {
data(save(0,content()));
mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"revision\":1,\"realName\":\"既有接口更新\",\"skills\":[\"Java\",\"SQL\"]}")).andExpect(status().isOk());
JsonNode d=read();assertEquals("既有接口更新",d.at("/content/basicInfo/realName").asText());
assertEquals(2,d.path("revision").asLong());
assertEquals(2,d.at("/content/skills").size());assertEquals("PROFICIENT",d.at("/content/skills/0/level").asText());
}
@Test void runtimeOpenApiDocumentsOnlineResumeAndPrivateContactFields() throws Exception {
String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode doc=mapper.readTree(json);
assertTrue(doc.path("paths").has(PATH));assertTrue(doc.path("paths").has(PATH+"/download"));
JsonNode basic=doc.at("/components/schemas/OnlineResumeBasicInfo/properties");
assertTrue(basic.has("phone"));assertTrue(basic.has("email"));
JsonNode entry=doc.at("/components/schemas/OnlineResumeEntryRequest/properties/entry");
assertFalse(entry.has("$ref"));assertEquals(4,entry.path("oneOf").size());
Set<String> variants=new HashSet<>();entry.path("oneOf").forEach(v->variants.add(v.path("$ref").asText()));
assertEquals(4,variants.size());
for(String name:List.of("OnlineResumeEducation","OnlineResumeWork","OnlineResumeProject","OnlineResumeSkill"))
assertFalse(doc.at("/components/schemas/"+name+"/additionalProperties").asBoolean(true));
JsonNode candidate=doc.at("/components/schemas/CandidateDetailView/properties");
assertFalse(candidate.has("phone"));assertFalse(candidate.has("email"));
for(String name:List.of("StudentProfileUpdateRequest","StudentPreferenceRequest")) {
JsonNode revision=doc.at("/components/schemas/"+name+"/properties/revision");
assertEquals("integer",revision.path("type").asText());
assertEquals("int64",revision.path("format").asText());
assertEquals(0,revision.path("minimum").asInt(-1));
assertTrue(revision.path("description").asText().contains("409/6002"));
}
for(String path:List.of("/profile/me","/profile/me/preferences"))
assertTrue(doc.path("paths").path(path).path("put").path("description").asText().contains("409/6002"));
String destination=System.getProperty("qsp.onlineResume.openapiOutput");
if(destination!=null)java.nio.file.Files.writeString(java.nio.file.Path.of(destination),json,StandardCharsets.UTF_8);
}
@Test void legacyWritesRequireCurrentRevisionAndFailuresAreAtomic() throws Exception {
JsonNode original=data(save(0,content()));
for(String endpoint:List.of("/profile/me","/profile/me/preferences")) {
for(String body:List.of("{}","{\"revision\":0}","{\"revision\":9}")) {
mvc.perform(put(endpoint).header("Authorization",token).contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002));
assertEquals(original,read());
}
}
mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"revision\":-1}")).andExpect(status().isBadRequest());
assertEquals(original,read());
}
@Test void legacyPreferenceWriteAdvancesRevisionAndBlocksStaleFullSave() throws Exception {
data(save(0,content()));
data(mvc.perform(put("/profile/me/preferences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"revision\":1,\"expectedSalaryMin\":500,\"preferredCities\":[]}")));
JsonNode latest=read();
assertEquals(2,latest.path("revision").asLong());
assertEquals(500,latest.at("/content/jobIntention/expectedSalaryMin").asInt());
assertEquals(0,latest.at("/content/jobIntention/preferredCities").size());
assertEquals(20000,latest.at("/content/jobIntention/expectedSalaryMax").asInt());
assertEquals("合成企业",latest.at("/content/workExperiences/0/company").asText());
save(1,content()).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002));
assertEquals(latest,read());
}
@Test void legacyPartialSalaryCannotCreateInvalidMergedRangeOrAdvanceRevision() throws Exception {
JsonNode original=data(save(0,content()));
for(String invalid:List.of("{\"revision\":1,\"expectedSalaryMin\":25000,\"preferredCities\":[\"不能写入\"]}",
"{\"revision\":1,\"expectedSalaryMax\":5000}")) {
mvc.perform(put("/profile/me/preferences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON).content(invalid))
.andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(1001));
assertEquals(original,read());
}
}
@Test void legacyClientsWithoutOnlineRecordRemainCompatibleAndReadsDoNotCreateRecord() throws Exception {
read();assertFalse(resumes.existsById(user.getId()));
data(mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"realName\":\"旧客户端\"}")));
data(mvc.perform(put("/profile/me/preferences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"expectedSalaryMin\":500}")));
assertFalse(resumes.existsById(user.getId()));
assertEquals(0,read().path("revision").asLong());
assertEquals("旧客户端",read().at("/content/basicInfo/realName").asText());
mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"revision\":7,\"realName\":\"不能覆盖\"}"))
.andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002));
}
@Test void legacyClearIsSynchronizedAndAttachmentIsNeverChanged() throws Exception {
profiles.save(StudentProfile.builder().userId(user.getId()).resumeUrl(user.getId()+"_unchanged0001.pdf")
.resumeOriginalName("合成.pdf").resumeSize(42L).build());
data(save(0,content()));
data(mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"revision\":1,\"realName\":\"\",\"bio\":\"\",\"skills\":[]}")));
JsonNode d=read();assertEquals(2,d.path("revision").asLong());
assertEquals("",d.at("/content/basicInfo/realName").asText());
assertEquals("",d.at("/content/selfEvaluation").asText());
assertEquals(0,d.at("/content/skills").size());
assertEquals(42,d.at("/attachment/size").asInt());
assertEquals(user.getId()+"_unchanged0001.pdf",profiles.findByUserId(user.getId()).orElseThrow().getResumeUrl());
}
@Test void legacyRevisionIsBoundToAuthenticatedUserNotInjectedIdentity() throws Exception {
JsonNode original=data(save(0,content()));User other=createUser(User.Role.STUDENT);
service.save(other.getId(),new OnlineResumeSaveRequest(0L,content()));
data(mvc.perform(put("/profile/me").header("Authorization",bearer(other))
.param("userId",user.getId().toString()).contentType(MediaType.APPLICATION_JSON)
.content("{\"revision\":1,\"realName\":\"其他账号更新\"}")));
assertEquals(original,read());
assertEquals("其他账号更新",service.get(other.getId()).content().getBasicInfo().getRealName());
assertEquals(2,service.get(other.getId()).revision());
}
@Test void concurrentLegacyAndOnlineSaveHaveExactlyOneWinner() throws Exception {
for(boolean preference:List.of(false,true)) {
long revision=data(save(read().path("revision").asLong(),content())).path("revision").asLong();
var pool=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1);
try {
var legacy=pool.submit(()->{assertTrue(start.await(5,TimeUnit.SECONDS));
String body=preference?"{\"revision\":"+revision+",\"expectedSalaryMin\":500}":
"{\"revision\":"+revision+",\"realName\":\"并发旧入口\"}";
return mapper.readTree(mvc.perform(put(preference?"/profile/me/preferences":"/profile/me")
.header("Authorization",token).contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)).path("code").asInt();});
var online=pool.submit(()->{assertTrue(start.await(5,TimeUnit.SECONDS));
return mapper.readTree(save(revision,content()).andReturn().getResponse()
.getContentAsString(StandardCharsets.UTF_8)).path("code").asInt();});
start.countDown();var results=List.of(legacy.get(30,TimeUnit.SECONDS),online.get(30,TimeUnit.SECONDS));
assertEquals(1,results.stream().filter(x->x==200).count());
assertEquals(1,results.stream().filter(x->x==6002).count());
assertEquals(revision+1,read().path("revision").asLong());
} finally {pool.shutdownNow();}
}
}
@Test void staleTransactionSnapshotCannotBypassCurrentRevisionCheck() throws Exception {
data(save(0,content()));var pool=Executors.newSingleThreadExecutor();
var readReady=new CountDownLatch(1);var changed=new CountDownLatch(1);
try {
var stale=pool.submit(()->{
var ex=assertThrows(com.mydreamjob.common.exception.BusinessException.class,()->
new org.springframework.transaction.support.TransactionTemplate(transactionManager).execute(s->{
assertEquals(1,service.get(user.getId()).revision());readReady.countDown();
try {assertTrue(changed.await(10,TimeUnit.SECONDS));} catch(InterruptedException e){throw new RuntimeException(e);}
var req=new com.mydreamjob.profile.dto.StudentProfileUpdateRequest();req.setRevision(1L);req.setRealName("旧快照不能覆盖");
profileService.updateProfile(user.getId(),req);return null;
}));
return ex.getErrorCodeInt();
});
assertTrue(readReady.await(10,TimeUnit.SECONDS));data(save(1,content()));changed.countDown();
assertEquals(6002,stale.get(30,TimeUnit.SECONDS));assertEquals(2,read().path("revision").asLong());
} finally {changed.countDown();pool.shutdownNow();}
}
@Test void logoutRevokesAccessAndRefreshWhileOtherSessionsRemainUsable() throws Exception {
var a=sessions.createSession(user,java.time.Instant.now(),new com.mydreamjob.audit.AuditContext());
var b=sessions.createSession(user,java.time.Instant.now(),new com.mydreamjob.audit.AuditContext());
String bearerA="Bearer "+a.accessToken(),bearerB="Bearer "+b.accessToken();
data(mvc.perform(post("/auth/logout").header("Authorization",bearerA)));
mvc.perform(get(PATH).header("Authorization",bearerA)).andExpect(status().isUnauthorized());
mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(Map.of("refreshToken",a.refreshToken())))).andExpect(status().isUnauthorized());
data(mvc.perform(get(PATH).header("Authorization",bearerB)));
data(mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(Map.of("refreshToken",b.refreshToken())))));
}
@Test void workExperienceTypesPersistAcrossFullSaveAndNewSession() throws Exception {
long revision=0;
for(String type:List.of("FULL_TIME","INTERNSHIP")) {
var c=content();c.getWorkExperiences().get(0).setExperienceType(type);
JsonNode saved=data(save(revision,c));revision++;
assertEquals(type,saved.at("/content/workExperiences/0/experienceType").asText());
JsonNode fresh=data(mvc.perform(get(PATH).header("Authorization",bearer(user))));
assertEquals(saved,fresh);assertEquals(revision,fresh.path("revision").asLong());
}
}
@Test void workEntryCrudCanChangeAndClearTypeWithSharedRevision() throws Exception {
var work=content().getWorkExperiences().get(0);work.setExperienceType("INTERNSHIP");
JsonNode created=data(mvc.perform(post(PATH+"/work-experiences").header("Authorization",token)
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",0,"entry",work)))));
String id=created.at("/content/workExperiences/0/id").asText();
assertEquals("INTERNSHIP",data(mvc.perform(get(PATH+"/work-experiences/"+id).header("Authorization",token)))
.path("experienceType").asText());
work.setExperienceType("FULL_TIME");
JsonNode updated=data(mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token)
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",1,"entry",work)))));
assertEquals("FULL_TIME",updated.at("/content/workExperiences/0/experienceType").asText());
var omitted=mapper.valueToTree(work);((com.fasterxml.jackson.databind.node.ObjectNode)omitted).remove("experienceType");
JsonNode cleared=data(mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token)
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",2,"entry",omitted)))));
assertUnclassified(cleared.at("/content/workExperiences/0/experienceType"));
((com.fasterxml.jackson.databind.node.ObjectNode)omitted).putNull("experienceType");
cleared=data(mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token)
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",3,"entry",omitted)))));
assertUnclassified(cleared.at("/content/workExperiences/0/experienceType"));
mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token)
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",2,"entry",work))))
.andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002));
assertEquals(cleared,read());
data(mvc.perform(delete(PATH+"/work-experiences/"+id).param("revision","4").header("Authorization",token)));
assertEquals(0,read().at("/content/workExperiences").size());
}
@Test void invalidWorkExperienceTypesAreRejectedAtomicallyByBothWriteRoutes() throws Exception {
JsonNode original=data(save(0,content()));
for(String invalid:List.of("","PART_TIME","internship"," INTERNSHIP ","实习")) {
var c=content();c.getWorkExperiences().get(0).setExperienceType(invalid);
save(1,c).andExpect(status().isBadRequest());
mvc.perform(post(PATH+"/work-experiences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(Map.of("revision",1,"entry",c.getWorkExperiences().get(0)))))
.andExpect(status().isBadRequest());
assertEquals(original,read());
}
}
@Test void historicalWorkWithoutTypeRemainsUnclassifiedWithoutReadSideEffects() throws Exception {
var old=(com.fasterxml.jackson.databind.node.ObjectNode)mapper.valueToTree(content());
((com.fasterxml.jackson.databind.node.ObjectNode)old.path("workExperiences").get(0)).remove("experienceType");
var row=new OnlineResume();row.setUserId(user.getId());row.setRevision(7);
row.setContentJson(mapper.writeValueAsString(old));row.setUpdatedAt(java.time.LocalDateTime.now());resumes.saveAndFlush(row);
String before=resumes.findById(user.getId()).orElseThrow().getContentJson();
JsonNode current=read();assertUnclassified(current.at("/content/workExperiences/0/experienceType"));
assertEquals(7,current.path("revision").asLong());
assertEquals(before,resumes.findById(user.getId()).orElseThrow().getContentJson());
var c=mapper.treeToValue(current.path("content"),OnlineResumeContent.class);
JsonNode saved=data(save(7,c));assertUnclassified(saved.at("/content/workExperiences/0/experienceType"));
assertEquals("合成企业",saved.at("/content/workExperiences/0/company").asText());
}
@Test void workExperienceTypeIsAccountScopedAndIncludedInDocx() throws Exception {
var c=content();c.getWorkExperiences().get(0).setExperienceType("INTERNSHIP");
var formal=new OnlineResumeContent.Work();formal.setCompany("正式企业");formal.setPosition("开发");
formal.setStartDate(LocalDate.of(2024,1,1));formal.setOngoing(true);formal.setExperienceType("FULL_TIME");c.getWorkExperiences().add(formal);
var unknown=new OnlineResumeContent.Work();unknown.setCompany("未分类企业");unknown.setPosition("开发");
unknown.setStartDate(LocalDate.of(2024,1,1));unknown.setOngoing(true);c.getWorkExperiences().add(unknown);
data(save(0,c));
User other=createUser(User.Role.STUDENT);
assertEquals(0,data(mvc.perform(get(PATH+"/work-experiences").header("Authorization",bearer(other)))).size());
byte[] bytes=mvc.perform(get(PATH+"/download").header("Authorization",token)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsByteArray();
String xml=null;try(var zip=new ZipInputStream(new ByteArrayInputStream(bytes),StandardCharsets.UTF_8)) {
java.util.zip.ZipEntry e;while((e=zip.getNextEntry())!=null)if(e.getName().equals("word/document.xml"))xml=new String(zip.readAllBytes(),StandardCharsets.UTF_8);
}
assertNotNull(xml);assertTrue(xml.contains("实习"));assertTrue(xml.contains("正式工作"));assertTrue(xml.contains("类型未区分"));
}
@Test void runtimeContractDocumentsOptionalNullableWorkExperienceType() throws Exception {
JsonNode doc=mapper.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8));
JsonNode work=doc.at("/components/schemas/OnlineResumeWork");
JsonNode type=work.path("properties").path("experienceType");
Set<String> types=new HashSet<>();type.path("type").forEach(t->types.add(t.asText()));
assertEquals(Set.of("string","null"),types);
assertEquals("^(FULL_TIME|INTERNSHIP)$",type.path("pattern").asText());
assertTrue(type.path("description").asText().contains("未区分"));
work.path("required").forEach(required->assertNotEquals("experienceType",required.asText()));
}
private void assertUnclassified(JsonNode type) {
// 全局 NON_NULL 序列化可省略空字段;二者都不能被误认成 FULL_TIME。
assertTrue(type.isMissingNode() || type.isNull(), "未分类经历不得推断为正式工作或实习");
}
}