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 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 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("<>&")); 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> 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 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 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(), "未分类经历不得推断为正式工作或实习"); } }