@ -37,6 +37,8 @@ class OnlineResumeIntegrationTest {
@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 ;
@ -208,8 +210,9 @@ class OnlineResumeIntegrationTest {
@Test void legacyProfileUpdatesAreReflectedAndSkillDetailsAreRetained ( ) throws Exception {
data ( save ( 0 , content ( ) ) ) ;
mvc . perform ( put ( "/profile/me" ) . header ( "Authorization" , token ) . contentType ( MediaType . APPLICATION_JSON )
. content ( "{\"realName\":\"既有接口更新\",\"skills\":[\"Java\",\"SQL\"]}" ) ) . andExpect ( status ( ) . isOk ( ) ) ;
. content ( "{\"revision\":1,\"re alName\":\"既有接口更新\",\"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 {
@ -226,7 +229,245 @@ class OnlineResumeIntegrationTest {
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 ( ) , "未分类经历不得推断为正式工作或实习" ) ;
}
}