29 changed files with 3288 additions and 49 deletions
File diff suppressed because it is too large
@ -0,0 +1,41 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.web.bind.annotation.*; |
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@RestController |
||||
|
@RequiredArgsConstructor |
||||
|
@Tag(name = "投递") |
||||
|
public class ApplicationAcceptanceController { |
||||
|
private final ApplicationAcceptanceService acceptance; |
||||
|
private final CurrentUserAccessor users; |
||||
|
private final RequestIdAccessor requestIds; |
||||
|
|
||||
|
public record AcceptanceView(Long applicationId, String status, LocalDateTime companyAcceptedAt, |
||||
|
boolean communicationAllowed) {} |
||||
|
|
||||
|
@Operation(summary = "企业接受投递并解锁沟通", description = "仅已认证的岗位所属企业;无请求体。SUBMITTED/VIEWED/INTERVIEW/HIRED 可接受,拒绝/撤回返回409。接受与招聘状态分离,不修改status、不填写双方评分;幂等返回原companyAcceptedAt。成功后双方可调用 POST /applications/{id}/conversation,无需双向评分达标。") |
||||
|
@ApiResponses({ |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="200",description="data 包含 applicationId、status、companyAcceptedAt、communicationAllowed=true"), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="401",description="未登录或会话不可用"), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="403",description="角色、企业认证或投递归属不符"), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="404",description="投递/企业不存在"), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="409",description="投递已拒绝或撤回,不能接受") |
||||
|
}) |
||||
|
@PostMapping("/applications/{id}/accept") |
||||
|
public ResponseEntity<ApiResponse<AcceptanceView>> accept(@PathVariable Long id, HttpServletRequest request) { |
||||
|
Application app = acceptance.accept(users.requireUserId(),id,AuditContext.from(request)); |
||||
|
AcceptanceView view = new AcceptanceView(app.getId(),app.getStatus(),app.getCompanyAcceptedAt(),true); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(view,requestIds.get(request))); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.company.VerifiedCompanyGuard; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobService; |
||||
|
import com.mydreamjob.notification.NotificationService; |
||||
|
import com.mydreamjob.security.UserRoleGuard; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import java.time.Clock; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class ApplicationAcceptanceService { |
||||
|
private final UserRoleGuard roles; |
||||
|
private final CompanyRepository companies; |
||||
|
private final ApplicationRepository applications; |
||||
|
private final JobService jobs; |
||||
|
private final ApplicationStatusHistoryRepository history; |
||||
|
private final AuditService audit; |
||||
|
private final NotificationService notifications; |
||||
|
private final Clock clock; |
||||
|
|
||||
|
/** 与撤回/状态推进使用相同投递行锁;重试只产生一次接受记录和通知。 */ |
||||
|
@Transactional |
||||
|
public Application accept(Long actorUserId, Long applicationId, AuditContext context) { |
||||
|
roles.requireCompany(actorUserId); |
||||
|
Company company = VerifiedCompanyGuard.requireVerified(companies.findByOwnerUserId(actorUserId).orElse(null)); |
||||
|
Application app = applications.findLockedById(applicationId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); |
||||
|
Job job = jobs.getJob(app.getJobId()); |
||||
|
if (!company.getId().equals(job.getCompanyId())) |
||||
|
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); |
||||
|
if (!Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus())) |
||||
|
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); |
||||
|
// 已录用仍可补记明确接受;已经接受时不覆盖原始时间/审计。
|
||||
|
if (app.getCompanyAcceptedAt() != null) return app; |
||||
|
app.setCompanyAcceptedAt(LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MICROS)); |
||||
|
app = applications.save(app); |
||||
|
history.save(ApplicationStatusHistory.builder().applicationId(app.getId()) |
||||
|
.operatorUserId(actorUserId).fromStatus(app.getStatus()).toStatus(app.getStatus()) |
||||
|
.reason("COMPANY_ACCEPTED: company accepted application; communication unlocked").build()); |
||||
|
audit.log(actorUserId,"ACCEPT_APPLICATION","APPLICATION",app.getId(), |
||||
|
"Company accepted application; communication unlocked",context); |
||||
|
notifications.publishEvent(app.getStudentUserId(),"APPLICATION_ACCEPTED","企业已接受投递", |
||||
|
"企业已接受你的投递,可以开始沟通","APPLICATION",app.getId(),"accept-application-"+app.getId()); |
||||
|
return app; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; |
||||
|
|
||||
|
/** 沟通资格与双向评分解耦;终止投递优先拒绝,历史匹配/已录用保持兼容。 */ |
||||
|
public final class ApplicationCommunicationPolicy { |
||||
|
private ApplicationCommunicationPolicy() {} |
||||
|
|
||||
|
public static boolean allows(Application app, ApplicationMatchRecord legacyMatch) { |
||||
|
if (app == null || app.getStatus() == null |
||||
|
|| !java.util.Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus())) |
||||
|
return false; |
||||
|
return app.getCompanyAcceptedAt() != null || "HIRED".equals(app.getStatus()) |
||||
|
|| (legacyMatch != null && ApplicationMatchRecord.STATUS_MATCHED.equals(legacyMatch.getStatus())); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,2 @@ |
|||||
|
-- 接受与招聘阶段分离;不猜测/回填历史接受时间,不改既有状态、评分、会话。 |
||||
|
ALTER TABLE applications ADD COLUMN company_accepted_at DATETIME(6) NULL; |
||||
@ -0,0 +1,189 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.bidirectionalmatch.*; |
||||
|
import com.mydreamjob.company.*; |
||||
|
import com.mydreamjob.job.*; |
||||
|
import com.mydreamjob.messaging.*; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import org.junit.jupiter.api.*; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
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.MockMvc; |
||||
|
import java.time.Clock; |
||||
|
import java.util.*; |
||||
|
import java.util.concurrent.*; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
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.*; |
||||
|
import org.springframework.http.MediaType; |
||||
|
|
||||
|
@SpringBootTest(properties="spring.datasource.url=jdbc:h2:mem:integrationdb_accept_chat;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
||||
|
@AutoConfigureMockMvc @ActiveProfiles("integration-test") |
||||
|
class ApplicationAcceptanceIntegrationTest { |
||||
|
@Autowired MockMvc mvc; |
||||
|
@Autowired ObjectMapper mapper; |
||||
|
@Autowired UserRepository users; |
||||
|
@Autowired CompanyRepository companies; |
||||
|
@Autowired JobRepository jobs; |
||||
|
@Autowired ApplicationRepository applications; |
||||
|
@Autowired ApplicationAcceptanceService acceptance; |
||||
|
@Autowired ApplicationService applicationService; |
||||
|
@Autowired ApplicationStatusHistoryRepository history; |
||||
|
@Autowired ApplicationMatchRecordRepository matches; |
||||
|
@Autowired MatchConversationService conversations; |
||||
|
@Autowired AuthSessionService sessions; |
||||
|
@Autowired Clock clock; |
||||
|
@Autowired com.mydreamjob.safety.BlockRelationRepository blocks; |
||||
|
@MockBean RedisTemplate<String,Object> redis; |
||||
|
static final AtomicLong PHONE=new AtomicLong(71000000); |
||||
|
User student,employer,other; |
||||
|
Company company; |
||||
|
Job job; |
||||
|
Application app; |
||||
|
String studentToken,employerToken; |
||||
|
|
||||
|
User user(User.Role role) { |
||||
|
return users.save(User.builder().phone("166"+PHONE.incrementAndGet()).nickname("隔离接受沟通测试") |
||||
|
.role(role).roles(Set.of(role)).active(true).verified(true).build()); |
||||
|
} |
||||
|
String token(User user) { return sessions.createSession(user,clock.instant(),AuditContext.builder().build()).accessToken(); } |
||||
|
@BeforeEach void setup() { |
||||
|
student=user(User.Role.STUDENT);employer=user(User.Role.COMPANY);other=user(User.Role.STUDENT); |
||||
|
company=companies.save(Company.builder().ownerUserId(employer.getId()).name("隔离企业").verified(true).build()); |
||||
|
job=jobs.save(Job.builder().companyId(company.getId()).title("仅测试").description("fixture") |
||||
|
.city("上海").employmentType("FULL_TIME").status("PUBLISHED").build()); |
||||
|
app=applications.save(Application.builder().jobId(job.getId()).studentUserId(student.getId()).status("SUBMITTED").build()); |
||||
|
studentToken=token(student);employerToken=token(employer); |
||||
|
} |
||||
|
String url(String suffix) {return "/applications/"+app.getId()+suffix;} |
||||
|
long conversation(String token) throws Exception { |
||||
|
String json=mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+token)) |
||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); |
||||
|
return mapper.readTree(json).path("data").path("id").asLong(); |
||||
|
} |
||||
|
void accept() { acceptance.accept(employer.getId(),app.getId(),null); } |
||||
|
|
||||
|
@Test void httpAcceptWithoutScoresThenBothSidesCanSendAndRead() throws Exception { |
||||
|
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)) |
||||
|
.andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(8304)); |
||||
|
mvc.perform(post("/conversations").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"participantUserId\":"+student.getId()+"}")).andExpect(status().isForbidden()) |
||||
|
.andExpect(jsonPath("$.code").value(8303)); |
||||
|
String response=mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.communicationAllowed").value(true)) |
||||
|
.andExpect(jsonPath("$.data.status").value("SUBMITTED")).andReturn().getResponse().getContentAsString(); |
||||
|
String acceptedAt=mapper.readTree(response).path("data").path("companyAcceptedAt").asText(); |
||||
|
assertFalse(acceptedAt.isBlank()); |
||||
|
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.companyAcceptedAt").value(acceptedAt)); |
||||
|
assertTrue(matches.findByApplicationId(app.getId()).isEmpty()); |
||||
|
mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken)) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PENDING_BOTH")) |
||||
|
.andExpect(jsonPath("$.data.communicationAllowed").value(true)); |
||||
|
long id=conversation(employerToken);assertEquals(id,conversation(studentToken)); |
||||
|
for(String auth:List.of(studentToken,employerToken)) { |
||||
|
mvc.perform(post("/conversations/messages").header("Authorization","Bearer "+auth).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"conversationId\":"+id+",\"content\":\"隔离双向沟通\",\"clientMessageId\":\"msg-"+auth.hashCode()+"\"}")) |
||||
|
.andExpect(status().isOk()); |
||||
|
} |
||||
|
for(String auth:List.of(studentToken,employerToken)) { |
||||
|
mvc.perform(get("/conversations/"+id+"/messages").header("Authorization","Bearer "+auth)) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(2)); |
||||
|
mvc.perform(get("/conversations/"+id+"/events").header("Authorization","Bearer "+auth)) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.items").isNotEmpty()); |
||||
|
} |
||||
|
long count=history.findByApplicationIdOrderByCreatedAtAsc(app.getId()).stream() |
||||
|
.filter(h->h.getReason().startsWith("COMPANY_ACCEPTED")).count(); |
||||
|
assertEquals(1,count); |
||||
|
} |
||||
|
@Test void historicalHiredHasClosedMatchButAllowsCommunication() throws Exception { |
||||
|
app.setStatus("HIRED");applications.save(app); |
||||
|
mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken)) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CLOSED")) |
||||
|
.andExpect(jsonPath("$.data.communicationAllowed").value(true)); |
||||
|
assertTrue(matches.existsMatchedApplication(company.getId(),student.getId())); |
||||
|
assertTrue(conversation(employerToken)>0); |
||||
|
} |
||||
|
@Test void viewingAloneDoesNotUnlock() { |
||||
|
app.setStatus("VIEWED");applications.save(app); |
||||
|
assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); |
||||
|
assertThrows(BusinessException.class,()->conversations.createOrGetMatchConversation(employer.getId(),app.getId())); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN"}) |
||||
|
void terminalWinsOverAcceptance(String status) throws Exception { |
||||
|
accept();app=applications.findById(app.getId()).orElseThrow();app.setStatus(status);applications.save(app); |
||||
|
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isConflict()); |
||||
|
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)).andExpect(status().isConflict()); |
||||
|
assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); |
||||
|
mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken)) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.communicationAllowed").value(false)); |
||||
|
} |
||||
|
@Test void studentOtherCompanyAndAnonymousCannotAccept() throws Exception { |
||||
|
mvc.perform(post(url("/accept"))).andExpect(status().isUnauthorized()); |
||||
|
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+studentToken)).andExpect(status().isForbidden()); |
||||
|
User foreign=user(User.Role.COMPANY); |
||||
|
companies.save(Company.builder().ownerUserId(foreign.getId()).name("别家企业").verified(true).build()); |
||||
|
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+token(foreign))).andExpect(status().isForbidden()); |
||||
|
assertNull(applications.findById(app.getId()).orElseThrow().getCompanyAcceptedAt()); |
||||
|
} |
||||
|
@Test void unverifiedAndDisabledCompaniesCannotAccept() throws Exception { |
||||
|
company.setVerified(false);companies.save(company); |
||||
|
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isForbidden()); |
||||
|
company.setVerified(true);companies.save(company); |
||||
|
employer.setActive(false);users.save(employer); |
||||
|
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isUnauthorized()); |
||||
|
assertNull(applications.findById(app.getId()).orElseThrow().getCompanyAcceptedAt()); |
||||
|
} |
||||
|
@Test void concurrentAcceptanceIsIdempotent() throws Exception { |
||||
|
ExecutorService pool=Executors.newFixedThreadPool(2);CountDownLatch start=new CountDownLatch(1); |
||||
|
try { |
||||
|
Callable<java.time.LocalDateTime> call=()->{start.await();return acceptance.accept(employer.getId(),app.getId(),null).getCompanyAcceptedAt();}; |
||||
|
var a=pool.submit(call);var b=pool.submit(call);start.countDown(); |
||||
|
assertEquals(a.get(15,TimeUnit.SECONDS),b.get(15,TimeUnit.SECONDS)); |
||||
|
assertEquals(1,history.findByApplicationIdOrderByCreatedAtAsc(app.getId()).stream() |
||||
|
.filter(h->h.getReason().startsWith("COMPANY_ACCEPTED")).count()); |
||||
|
} finally {pool.shutdownNow();} |
||||
|
} |
||||
|
@Test void revokedCompanyCertificationCannotBeBypassedByStudent() throws Exception { |
||||
|
accept();company.setVerified(false);companies.save(company); |
||||
|
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+studentToken)).andExpect(status().isForbidden()); |
||||
|
mvc.perform(post("/conversations").header("Authorization","Bearer "+studentToken).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"participantUserId\":"+employer.getId()+"}")).andExpect(status().isForbidden()); |
||||
|
} |
||||
|
@Test void withdrawalAndReapplicationDoNotInheritAcceptance() throws Exception { |
||||
|
accept();long oldId=app.getId();long oldConversation=conversation(employerToken); |
||||
|
applicationService.withdraw(student.getId(),oldId); |
||||
|
var request=new com.mydreamjob.application.dto.ApplicationCreateRequest();request.setJobId(job.getId()); |
||||
|
Application next=applicationService.apply(student.getId(),request); |
||||
|
assertNull(next.getCompanyAcceptedAt());assertEquals(oldId,next.getPreviousApplicationId()); |
||||
|
assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); |
||||
|
mvc.perform(post("/conversations/messages").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"conversationId\":"+oldConversation+",\"content\":\"撤回后禁止发送\"}")).andExpect(status().isForbidden()); |
||||
|
app=next;accept();assertEquals(oldConversation,conversation(employerToken)); |
||||
|
} |
||||
|
@Test void runtimeContractIncludesAcceptanceAndCapabilities() throws Exception { |
||||
|
String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); |
||||
|
assertTrue(mapper.readTree(json).path("paths").has("/applications/{id}/accept")); |
||||
|
java.nio.file.Files.writeString(java.nio.file.Path.of("target/acceptance-chat-openapi.json"),json); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(booleans={true,false}) |
||||
|
void acceptedPairCannotBypassEitherDirectionOfBlocking(boolean forward) throws Exception { |
||||
|
accept(); |
||||
|
blocks.save(com.mydreamjob.safety.BlockRelation.builder() |
||||
|
.blockerUserId(forward?student.getId():employer.getId()) |
||||
|
.blockedUserId(forward?employer.getId():student.getId()).build()); |
||||
|
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)).andExpect(status().isForbidden()); |
||||
|
mvc.perform(post("/conversations").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"participantUserId\":"+student.getId()+"}")).andExpect(status().isForbidden()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import org.springframework.test.context.DynamicPropertyRegistry; |
||||
|
import org.springframework.test.context.DynamicPropertySource; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.junit.jupiter.Container; |
||||
|
import org.testcontainers.junit.jupiter.Testcontainers; |
||||
|
|
||||
|
/** 同一组真实控制器、权限、接受并发、聊天双向读写与撤回探针,在真实 MySQL 重用。 */ |
||||
|
@Testcontainers |
||||
|
@org.springframework.test.annotation.DirtiesContext(classMode=org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) |
||||
|
class ApplicationAcceptanceMySqlIntegrationTest extends ApplicationAcceptanceIntegrationTest { |
||||
|
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>( |
||||
|
System.getProperty("acceptance.mysql.image","mysql:8.4")) |
||||
|
.withDatabaseName("acceptance_chat").withUsername("accepttest").withPassword("synthetic-only") |
||||
|
.withCommand("--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci") |
||||
|
.withUrlParam("characterEncoding","UTF-8") |
||||
|
.withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024)); |
||||
|
@DynamicPropertySource static void mysql(DynamicPropertyRegistry registry) { |
||||
|
registry.add("spring.datasource.url",MYSQL::getJdbcUrl); |
||||
|
registry.add("spring.datasource.username",MYSQL::getUsername); |
||||
|
registry.add("spring.datasource.password",MYSQL::getPassword); |
||||
|
registry.add("spring.datasource.driver-class-name",()->"com.mysql.cj.jdbc.Driver"); |
||||
|
registry.add("spring.jpa.properties.hibernate.dialect",()->"org.hibernate.dialect.MySQLDialect"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import com.mydreamjob.config.MySql57MigrationConfiguration; |
||||
|
import org.flywaydb.core.Flyway; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import java.sql.*; |
||||
|
import java.util.*; |
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class ApplicationAcceptanceMySqlMigrationTest { |
||||
|
@Test void mysql57V60ToV61() throws Exception {verify("mysql:5.7.36");} |
||||
|
@Test void mysql84V60ToV61() throws Exception {verify("mysql:8.4");} |
||||
|
Flyway flyway(MySQLContainer<?> db,String version) { |
||||
|
var config=Flyway.configure().dataSource(db.getJdbcUrl(),db.getUsername(),db.getPassword()) |
||||
|
.locations("classpath:db/migration").target(version); |
||||
|
MySql57MigrationConfiguration.configure(config);return config.load(); |
||||
|
} |
||||
|
void verify(String image) throws Exception { |
||||
|
try(var db=new MySQLContainer<>(image).withDatabaseName("acceptance_migration").withUsername("accepttest") |
||||
|
.withUrlParam("characterEncoding","UTF-8") |
||||
|
.withPassword("synthetic-only").withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024))) { |
||||
|
db.start(); |
||||
|
try(var connection=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword()); |
||||
|
var sql=connection.createStatement()) { |
||||
|
sql.execute("ALTER DATABASE acceptance_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); |
||||
|
flyway(db,"60").migrate();var before=history(sql); |
||||
|
sql.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(940001,'19900881111','COMPANY',1,1),(940002,'19900882222','STUDENT',1,1)"); |
||||
|
sql.execute("INSERT INTO companies(id,owner_user_id,name,verified) VALUES(940001,940001,'test',1)"); |
||||
|
sql.execute("INSERT INTO jobs(id,company_id,title,description,city,employment_type,status) VALUES(940001,940001,'test','fixture','test','FULL_TIME','PUBLISHED')"); |
||||
|
sql.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(940001,940001,940002,'HIRED')"); |
||||
|
assertEquals(1,flyway(db,"61").migrate().migrationsExecuted); |
||||
|
assertEquals(before,history(sql)); |
||||
|
try(var rows=sql.executeQuery("SELECT status,company_accepted_at FROM applications WHERE id=940001")) { |
||||
|
assertTrue(rows.next());assertEquals("HIRED",rows.getString(1));assertNull(rows.getTimestamp(2)); |
||||
|
} |
||||
|
try(var rows=sql.executeQuery("SELECT data_type,datetime_precision,is_nullable FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='applications' AND column_name='company_accepted_at'")) { |
||||
|
assertTrue(rows.next());assertEquals("datetime",rows.getString(1));assertEquals(6,rows.getInt(2));assertEquals("YES",rows.getString(3)); |
||||
|
} |
||||
|
sql.execute("UPDATE applications SET company_accepted_at='2026-10-22 12:00:00.123456' WHERE id=940001"); |
||||
|
assertEquals(0,flyway(db,"61").migrate().migrationsExecuted);flyway(db,"61").validate(); |
||||
|
try(var rows=sql.executeQuery("SELECT CAST(company_accepted_at AS CHAR) FROM applications WHERE id=940001")) { |
||||
|
assertTrue(rows.next());assertTrue(rows.getString(1).endsWith(".123456")); |
||||
|
} |
||||
|
assertEquals(before,history(sql)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
List<String> history(Statement sql) throws Exception { |
||||
|
var result=new ArrayList<String>(); |
||||
|
try(var rows=sql.executeQuery("SELECT version,checksum,success FROM flyway_schema_history WHERE version<>'61' ORDER BY installed_rank")) { |
||||
|
while(rows.next())result.add(rows.getString(1)+"|"+rows.getString(2)+"|"+rows.getBoolean(3)); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.company.*; |
||||
|
import com.mydreamjob.job.*; |
||||
|
import com.mydreamjob.notification.NotificationService; |
||||
|
import com.mydreamjob.security.UserRoleGuard; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import org.junit.jupiter.api.*; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.mockito.*; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import java.time.*; |
||||
|
import java.util.Optional; |
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class ApplicationAcceptanceServiceTest { |
||||
|
@Mock UserRoleGuard roles; |
||||
|
@Mock CompanyRepository companies; |
||||
|
@Mock ApplicationRepository applications; |
||||
|
@Mock JobService jobs; |
||||
|
@Mock ApplicationStatusHistoryRepository history; |
||||
|
@Mock AuditService audit; |
||||
|
@Mock NotificationService notifications; |
||||
|
final Clock clock=Clock.fixed(Instant.parse("2026-10-22T12:00:00Z"),ZoneOffset.UTC); |
||||
|
ApplicationAcceptanceService service; |
||||
|
Application app; |
||||
|
@BeforeEach void setup() { |
||||
|
service=new ApplicationAcceptanceService(roles,companies,applications,jobs,history,audit,notifications,clock); |
||||
|
app=Application.builder().id(15L).jobId(3L).studentUserId(62L).status("SUBMITTED").build(); |
||||
|
} |
||||
|
void fixture() { |
||||
|
when(companies.findByOwnerUserId(64L)).thenReturn(Optional.of(Company.builder().id(54L).verified(true).build())); |
||||
|
when(applications.findLockedById(15L)).thenReturn(Optional.of(app)); |
||||
|
when(jobs.getJob(3L)).thenReturn(Job.builder().id(3L).companyId(54L).build()); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW","HIRED"}) |
||||
|
void acceptIsExplicitAndDoesNotAdvanceStatus(String status) { |
||||
|
fixture();app.setStatus(status);when(applications.save(app)).thenReturn(app); |
||||
|
var result=service.accept(64L,15L,null); |
||||
|
assertEquals(status,result.getStatus());assertEquals(LocalDateTime.now(clock),result.getCompanyAcceptedAt()); |
||||
|
verify(roles).requireCompany(64L); |
||||
|
verify(history).save(argThat(h->h.getOperatorUserId().equals(64L) |
||||
|
&&h.getFromStatus().equals(status)&&h.getToStatus().equals(status))); |
||||
|
verify(audit).log(eq(64L),eq("ACCEPT_APPLICATION"),eq("APPLICATION"),eq(15L),anyString(),isNull()); |
||||
|
verify(notifications).publishEvent(eq(62L),eq("APPLICATION_ACCEPTED"),anyString(),anyString(),eq("APPLICATION"),eq(15L),eq("accept-application-15")); |
||||
|
} |
||||
|
@Test void repeatedAcceptanceDoesNotOverwriteOrNotifyAgain() { |
||||
|
fixture();var original=LocalDateTime.now(clock).minusDays(1);app.setCompanyAcceptedAt(original); |
||||
|
assertSame(app,service.accept(64L,15L,null)); |
||||
|
assertEquals(original,app.getCompanyAcceptedAt());verifyNoInteractions(history,audit,notifications); |
||||
|
verify(applications,never()).save(any()); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN"}) |
||||
|
void terminalCannotBeAcceptedEvenIfPreviouslyAccepted(String status) { |
||||
|
fixture();app.setStatus(status);app.setCompanyAcceptedAt(LocalDateTime.now(clock)); |
||||
|
assertThrows(BusinessException.class,()->service.accept(64L,15L,null)); |
||||
|
verifyNoInteractions(history,audit,notifications); |
||||
|
} |
||||
|
@Test void anotherCompanyCannotAccept() { |
||||
|
fixture();when(jobs.getJob(3L)).thenReturn(Job.builder().companyId(99L).build()); |
||||
|
assertThrows(BusinessException.class,()->service.accept(64L,15L,null)); |
||||
|
assertNull(app.getCompanyAcceptedAt());verifyNoInteractions(history,audit,notifications); |
||||
|
} |
||||
|
@Test void unverifiedCompanyCannotAccept() { |
||||
|
when(companies.findByOwnerUserId(64L)).thenReturn(Optional.of(Company.builder().id(54L).verified(false).build())); |
||||
|
assertThrows(BusinessException.class,()->service.accept(64L,15L,null)); |
||||
|
verifyNoInteractions(applications,history,audit,notifications); |
||||
|
} |
||||
|
@Test void roleFailureHasNoSideEffects() { |
||||
|
doThrow(new BusinessException(com.mydreamjob.common.exception.ErrorCode.FORBIDDEN)).when(roles).requireCompany(62L); |
||||
|
assertThrows(BusinessException.class,()->service.accept(62L,15L,null)); |
||||
|
verifyNoInteractions(companies,applications,history,audit,notifications); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
package com.mydreamjob.application; |
||||
|
|
||||
|
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import java.time.LocalDateTime; |
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class ApplicationCommunicationPolicyTest { |
||||
|
@ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW"}) |
||||
|
void viewingOrEnteringInterviewDoesNotMeanAcceptance(String status) { |
||||
|
assertFalse(ApplicationCommunicationPolicy.allows(Application.builder().status(status).build(),null)); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW"}) |
||||
|
void acceptedDoesNotRequireAnyScore(String status) { |
||||
|
assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status(status) |
||||
|
.companyAcceptedAt(LocalDateTime.now()).build(),null)); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN","UNKNOWN"}) |
||||
|
void terminalAndUnknownWinOverAcceptanceAndMatch(String status) { |
||||
|
assertFalse(ApplicationCommunicationPolicy.allows(Application.builder().status(status) |
||||
|
.companyAcceptedAt(LocalDateTime.now()).build(),ApplicationMatchRecord.builder().status("MATCHED").build())); |
||||
|
} |
||||
|
@Test void historicalHiredAndMatchedRemainUsable() { |
||||
|
assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status("HIRED").build(),null)); |
||||
|
assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status("VIEWED").build(), |
||||
|
ApplicationMatchRecord.builder().status("MATCHED").build())); |
||||
|
assertFalse(ApplicationCommunicationPolicy.allows(null,null)); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue