feat(education): add question import job application slice

This commit is contained in:
2026-07-31 12:47:58 +08:00
parent 3744c8c3be
commit 82a0a7fc50
16 changed files with 638 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.education.controller.admin.importjob;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportExecuteReqVO;
import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportJobRespVO;
import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportPreviewReqVO;
import cn.iocoder.yudao.module.education.service.importjob.ExecuteQuestionImportCommand;
import cn.iocoder.yudao.module.education.service.importjob.PreviewQuestionImportCommand;
import cn.iocoder.yudao.module.education.service.importjob.QuestionImportJobService;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@RestController
@RequestMapping("/education/question-import-jobs")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class QuestionImportJobController {
private final QuestionImportJobService service;
public QuestionImportJobController(QuestionImportJobService service) { this.service = service; }
@PostMapping("/preview")
@PreAuthorize("@ss.hasPermission('education:question-import:create')")
public CommonResult<QuestionImportJobRespVO> preview(@Valid @RequestBody QuestionImportPreviewReqVO request) {
return success(QuestionImportJobRespVO.from(service.requestPreview(new PreviewQuestionImportCommand(
request.getCommandKey(), request.getObjectKey(), request.getFileName(), request.getMediaType(),
request.getFileSize()), getLoginUserId())));
}
@PostMapping("/{id}/execute")
@PreAuthorize("@ss.hasPermission('education:question-import:execute')")
public CommonResult<QuestionImportJobRespVO> execute(@PathVariable("id") Long id,
@Valid @RequestBody QuestionImportExecuteReqVO request) {
return success(QuestionImportJobRespVO.from(service.requestExecute(
new ExecuteQuestionImportCommand(id, request.getCommandKey()), getLoginUserId())));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('education:question-import:query')")
public CommonResult<QuestionImportJobRespVO> get(@PathVariable("id") Long id) {
return success(QuestionImportJobRespVO.from(service.get(id)));
}
}

View File

@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.education.controller.admin.importjob.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class QuestionImportExecuteReqVO {
@NotBlank private String commandKey;
}

View File

@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.education.controller.admin.importjob.vo;
import cn.iocoder.yudao.module.education.service.importjob.QuestionImportJobProjection;
import lombok.Data;
@Data
public class QuestionImportJobRespVO {
private Long id;
private String status;
private String scanStatus;
private String parserStatus;
private String fileName;
private Long fileSize;
private Integer previewQuestionCount;
private Integer importedQuestionCount;
private String failureCode;
public static QuestionImportJobRespVO from(QuestionImportJobProjection source) {
QuestionImportJobRespVO target = new QuestionImportJobRespVO();
target.id = source.id(); target.status = source.status(); target.scanStatus = source.scanStatus();
target.parserStatus = source.parserStatus(); target.fileName = source.fileName(); target.fileSize = source.fileSize();
target.previewQuestionCount = source.previewQuestionCount(); target.importedQuestionCount = source.importedQuestionCount();
target.failureCode = source.failureCode();
return target;
}
}

View File

@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.education.controller.admin.importjob.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
@Data
public class QuestionImportPreviewReqVO {
@NotBlank private String commandKey;
@NotBlank private String objectKey;
@NotBlank private String fileName;
@NotBlank private String mediaType;
@NotNull @Positive private Long fileSize;
}

View File

@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.education.dal.dataobject.importjob;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@TableName("education_question_import_job")
@KeySequence("education_question_import_job_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class QuestionImportJobDO extends TenantBaseDO {
@TableId private Long id;
private Long actorId;
private String objectKey;
private String fileName;
private String mediaType;
private Long fileSize;
private String previewCommandKey;
private String previewRequestHash;
private String executeCommandKey;
private String executeRequestHash;
private String status;
private String scanStatus;
private String parserStatus;
private String parsedPayload;
private Integer previewQuestionCount;
private Integer importedQuestionCount;
private String failureCode;
}

View File

@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.education.dal.mysql.importjob;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.QuestionImportJobDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QuestionImportJobMapper extends BaseMapperX<QuestionImportJobDO> {
default QuestionImportJobDO selectByPreviewCommand(Long tenantId, String commandKey) {
return selectOne(new LambdaQueryWrapperX<QuestionImportJobDO>()
.eq(QuestionImportJobDO::getTenantId, tenantId)
.eq(QuestionImportJobDO::getPreviewCommandKey, commandKey));
}
default QuestionImportJobDO selectByExecuteCommand(Long tenantId, String commandKey) {
return selectOne(new LambdaQueryWrapperX<QuestionImportJobDO>()
.eq(QuestionImportJobDO::getTenantId, tenantId)
.eq(QuestionImportJobDO::getExecuteCommandKey, commandKey));
}
default QuestionImportJobDO selectTenantJob(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<QuestionImportJobDO>()
.eq(QuestionImportJobDO::getTenantId, tenantId).eq(QuestionImportJobDO::getId, id));
}
default QuestionImportJobDO claimPreview(Long id) {
QuestionImportJobDO job = selectById(id);
return job != null && "PREVIEW_PENDING".equals(job.getStatus()) ? job : null;
}
default QuestionImportJobDO claimExecute(Long id) {
QuestionImportJobDO job = selectById(id);
return job != null && "EXECUTE_PENDING".equals(job.getStatus()) ? job : null;
}
default int completePreview(QuestionImportJobDO job) { return updateById(job); }
default int failPreview(Long id, String failureCode) {
QuestionImportJobDO job = new QuestionImportJobDO();
job.setId(id); job.setStatus("PREVIEW_FAILED"); job.setFailureCode(failureCode);
return updateById(job);
}
default int completeExecute(Long id, int count) {
QuestionImportJobDO job = new QuestionImportJobDO();
job.setId(id); job.setStatus("COMPLETED"); job.setImportedQuestionCount(count);
return updateById(job);
}
default int failExecute(Long id, String failureCode) {
QuestionImportJobDO job = new QuestionImportJobDO();
job.setId(id); job.setStatus("EXECUTE_FAILED"); job.setFailureCode(failureCode);
return updateById(job);
}
}

View File

@@ -109,6 +109,15 @@ public interface ErrorCodeConstants {
ErrorCode QUESTION_PLACEMENT_REQUIRED = new ErrorCode(1_005_003_076, "题目尚未归类,无法发布");
ErrorCode QUESTION_ACTIVE_COLLECTION_CONFLICT = new ErrorCode(1_005_003_077, "题目仍属于已发布题集,无法归档");
// ========== 题目导入任务 1-005-003-080 ~ 1-005-003-089 ==========
ErrorCode QUESTION_IMPORT_NOT_FOUND = new ErrorCode(1_005_003_080, "题目导入任务不存在或无权访问");
ErrorCode QUESTION_IMPORT_COMMAND_CONFLICT = new ErrorCode(1_005_003_081, "导入命令幂等键已用于不同请求");
ErrorCode QUESTION_IMPORT_STATE_CONFLICT = new ErrorCode(1_005_003_082, "题目导入任务状态不允许当前操作");
ErrorCode QUESTION_IMPORT_SCAN_NOT_CLEAN = new ErrorCode(1_005_003_083, "导入文件未通过安全扫描");
ErrorCode QUESTION_IMPORT_PARSER_UNAVAILABLE = new ErrorCode(1_005_003_084, "导入解析器不可用");
ErrorCode QUESTION_IMPORT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_003_085,
"当前题库数据源模式不支持执行导入:{}");
// ========== 内容节点创作 1-005-002-020 ~ 1-005-002-029 ==========
ErrorCode CONTENT_NODE_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_020,
"当前题库数据源模式不支持内容节点创作:{}");

View File

@@ -0,0 +1,4 @@
package cn.iocoder.yudao.module.education.service.importjob;
public record ExecuteQuestionImportCommand(Long jobId, String commandKey) {
}

View File

@@ -0,0 +1,6 @@
package cn.iocoder.yudao.module.education.service.importjob;
public interface ImportObjectScanGateway {
enum ScanResult { CLEAN, INFECTED, ERROR }
ScanResult scan(String objectKey);
}

View File

@@ -0,0 +1,5 @@
package cn.iocoder.yudao.module.education.service.importjob;
public record PreviewQuestionImportCommand(String commandKey, String objectKey, String fileName,
String mediaType, Long fileSize) {
}

View File

@@ -0,0 +1,6 @@
package cn.iocoder.yudao.module.education.service.importjob;
public record QuestionImportJobProjection(Long id, String status, String scanStatus, String parserStatus,
String fileName, Long fileSize, Integer previewQuestionCount,
Integer importedQuestionCount, String failureCode) {
}

View File

@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.education.service.importjob;
public interface QuestionImportJobService {
QuestionImportJobProjection requestPreview(PreviewQuestionImportCommand command, Long actorId);
QuestionImportJobProjection requestExecute(ExecuteQuestionImportCommand command, Long actorId);
QuestionImportJobProjection get(Long jobId);
void preview(Long jobId);
void execute(Long jobId);
}

View File

@@ -0,0 +1,181 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.QuestionImportJobDO;
import cn.iocoder.yudao.module.education.dal.mysql.importjob.QuestionImportJobMapper;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private final EducationProperties properties;
private final QuestionImportJobMapper jobMapper;
private final Optional<ImportObjectScanGateway> scanGateway;
private final Optional<QuestionImportParser> parser;
private final TenantQuestionLifecycleService lifecycleService;
public QuestionImportJobServiceImpl(EducationProperties properties, QuestionImportJobMapper jobMapper,
Optional<ImportObjectScanGateway> scanGateway,
Optional<QuestionImportParser> parser,
TenantQuestionLifecycleService lifecycleService) {
this.properties = properties;
this.jobMapper = jobMapper;
this.scanGateway = scanGateway;
this.parser = parser;
this.lifecycleService = lifecycleService;
}
@Override
@Transactional(rollbackFor = Exception.class)
public QuestionImportJobProjection requestPreview(PreviewQuestionImportCommand command, Long actorId) {
Long tenantId = TenantContextHolder.getRequiredTenantId();
String requestHash = hash(command);
QuestionImportJobDO existing = jobMapper.selectByPreviewCommand(tenantId, command.commandKey());
if (existing != null) {
requireHash(existing.getPreviewRequestHash(), requestHash);
return project(existing);
}
QuestionImportJobDO job = new QuestionImportJobDO();
job.setTenantId(tenantId);
job.setActorId(actorId);
job.setObjectKey(command.objectKey());
job.setFileName(command.fileName());
job.setMediaType(command.mediaType());
job.setFileSize(command.fileSize());
job.setPreviewCommandKey(command.commandKey());
job.setPreviewRequestHash(requestHash);
job.setStatus("PREVIEW_PENDING");
job.setScanStatus("PENDING");
job.setParserStatus("PENDING");
jobMapper.insert(job);
return project(job);
}
@Override
@Transactional(rollbackFor = Exception.class)
public QuestionImportJobProjection requestExecute(ExecuteQuestionImportCommand command, Long actorId) {
assertJavaRead();
Long tenantId = TenantContextHolder.getRequiredTenantId();
QuestionImportJobDO replay = jobMapper.selectByExecuteCommand(tenantId, command.commandKey());
String requestHash = hash(command);
if (replay != null) {
requireHash(replay.getExecuteRequestHash(), requestHash);
return project(replay);
}
QuestionImportJobDO job = requireJob(tenantId, command.jobId());
if (!"PREVIEW_READY".equals(job.getStatus()) || !"CLEAN".equals(job.getScanStatus())
|| !"PARSED".equals(job.getParserStatus()) || job.getParsedPayload() == null) {
throw exception(QUESTION_IMPORT_STATE_CONFLICT);
}
job.setActorId(actorId);
job.setExecuteCommandKey(command.commandKey());
job.setExecuteRequestHash(requestHash);
job.setStatus("EXECUTE_PENDING");
jobMapper.updateById(job);
return project(job);
}
@Override
public QuestionImportJobProjection get(Long jobId) {
return project(requireJob(TenantContextHolder.getRequiredTenantId(), jobId));
}
@Override
public void preview(Long jobId) {
QuestionImportJobDO claimed = jobMapper.claimPreview(jobId);
if (claimed == null) return;
TenantUtils.execute(claimed.getTenantId(), () -> processPreview(claimed));
}
private void processPreview(QuestionImportJobDO job) {
ImportObjectScanGateway gateway = scanGateway.orElse(null);
if (gateway == null) {
jobMapper.failPreview(job.getId(), "SCAN_UNAVAILABLE");
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
}
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(job.getObjectKey());
if (scanResult != ImportObjectScanGateway.ScanResult.CLEAN) {
jobMapper.failPreview(job.getId(), "SCAN_" + scanResult.name());
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
}
job.setScanStatus("CLEAN");
QuestionImportParser availableParser = parser.orElse(null);
if (availableParser == null) {
job.setParserStatus("UNAVAILABLE");
job.setParsedPayload(null);
job.setPreviewQuestionCount(null);
} else {
QuestionImportParser.ParsedImport parsed = availableParser.parse(job.getObjectKey());
job.setParserStatus("PARSED");
job.setParsedPayload(parsed.payload());
job.setPreviewQuestionCount(parsed.questions().size());
}
job.setStatus("PREVIEW_READY");
jobMapper.completePreview(job);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void execute(Long jobId) {
QuestionImportJobDO claimed = jobMapper.claimExecute(jobId);
if (claimed == null) return;
assertJavaRead();
TenantUtils.execute(claimed.getTenantId(), () -> processExecute(claimed));
}
private void processExecute(QuestionImportJobDO job) {
if (!"CLEAN".equals(job.getScanStatus())) throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
QuestionImportParser availableParser = parser.orElseThrow(() -> exception(QUESTION_IMPORT_PARSER_UNAVAILABLE));
List<QuestionDraftCommand> questions = availableParser.restore(job.getParsedPayload());
for (QuestionDraftCommand question : questions) lifecycleService.createDraft(question);
jobMapper.completeExecute(job.getId(), questions.size());
}
private QuestionImportJobDO requireJob(Long tenantId, Long id) {
QuestionImportJobDO job = jobMapper.selectTenantJob(tenantId, id);
if (job == null) throw exception(QUESTION_IMPORT_NOT_FOUND);
return job;
}
private void assertJavaRead() {
if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) {
throw exception(QUESTION_IMPORT_PROVIDER_UNSUPPORTED, properties.getCatalogMode());
}
}
private void requireHash(String expected, String actual) {
if (expected != null && !expected.equals(actual)) throw exception(QUESTION_IMPORT_COMMAND_CONFLICT);
}
private String hash(Object command) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(JsonUtils.toJsonString(command).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private QuestionImportJobProjection project(QuestionImportJobDO job) {
return new QuestionImportJobProjection(job.getId(), job.getStatus(), job.getScanStatus(),
job.getParserStatus(), job.getFileName(), job.getFileSize(), job.getPreviewQuestionCount(),
job.getImportedQuestionCount(), job.getFailureCode());
}
}

View File

@@ -0,0 +1,10 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import java.util.List;
public interface QuestionImportParser {
ParsedImport parse(String objectKey);
List<QuestionDraftCommand> restore(String payload);
record ParsedImport(String payload, List<QuestionDraftCommand> questions) {}
}

View File

@@ -0,0 +1,73 @@
package cn.iocoder.yudao.module.education.controller.admin.importjob;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportExecuteReqVO;
import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportPreviewReqVO;
import cn.iocoder.yudao.module.education.service.importjob.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class QuestionImportJobControllerTest {
private MutableSecurity security;
private RecordingService service;
private QuestionImportJobController controller;
@BeforeEach
void setUp() {
security = new MutableSecurity(); service = new RecordingService(); controller = new QuestionImportJobController(service);
LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null));
}
@AfterEach void tearDown() { SecurityContextHolder.clearContext(); }
@Test
void controllerMapsPreviewRequestAndActor() {
QuestionImportPreviewReqVO request = new QuestionImportPreviewReqVO();
request.setCommandKey("preview-1"); request.setObjectKey("object/10/q.csv"); request.setFileName("q.csv");
request.setMediaType("text/csv"); request.setFileSize(12L);
assertEquals(101L, controller.preview(request).getData().getId());
assertEquals("preview-1", service.preview.commandKey());
assertEquals(7L, service.actorId);
}
@Test
void controllerMapsExecuteRequestAndActor() {
QuestionImportExecuteReqVO request = new QuestionImportExecuteReqVO(); request.setCommandKey("execute-1");
assertEquals("EXECUTE_PENDING", controller.execute(101L, request).getData().getStatus());
assertEquals(new ExecuteQuestionImportCommand(101L, "execute-1"), service.execute);
assertEquals(7L, service.actorId);
}
private static final class RecordingService implements QuestionImportJobService {
PreviewQuestionImportCommand preview; ExecuteQuestionImportCommand execute; Long actorId;
public QuestionImportJobProjection requestPreview(PreviewQuestionImportCommand command, Long actorId) {
this.preview=command; this.actorId=actorId; return projection("PREVIEW_PENDING");
}
public QuestionImportJobProjection requestExecute(ExecuteQuestionImportCommand command, Long actorId) {
this.execute=command; this.actorId=actorId; return projection("EXECUTE_PENDING");
}
public QuestionImportJobProjection get(Long id) { return projection("PREVIEW_READY"); }
public void preview(Long id) {} public void execute(Long id) {}
private QuestionImportJobProjection projection(String status) {
return new QuestionImportJobProjection(101L,status,"CLEAN","PARSED","q.csv",12L,2,null,null);
}
}
private static final class MutableSecurity implements SecurityFrameworkService {
private final Set<String> permissions = new HashSet<>();
public boolean hasPermission(String permission) { return permissions.contains(permission); }
public boolean hasAnyPermissions(String... permissions) { return false; }
public boolean hasRole(String role) { return false; } public boolean hasAnyRoles(String... roles) { return false; }
public boolean hasScope(String scope) { return false; } public boolean hasAnyScopes(String... scopes) { return false; }
}
}

View File

@@ -0,0 +1,151 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.QuestionImportJobDO;
import cn.iocoder.yudao.module.education.dal.mysql.importjob.QuestionImportJobMapper;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.QUESTION_IMPORT_SCAN_NOT_CLEAN;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class QuestionImportJobServiceImplTest {
@Mock private QuestionImportJobMapper jobMapper;
@Mock private ImportObjectScanGateway scanGateway;
@Mock private QuestionImportParser parser;
@Mock private TenantQuestionLifecycleService lifecycleService;
private EducationProperties properties;
private QuestionImportJobServiceImpl service;
@BeforeEach
void setUp() {
properties = new EducationProperties();
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.of(scanGateway),
Optional.of(parser), lifecycleService);
TenantContextHolder.setTenantId(10L);
}
@AfterEach
void tearDown() {
TenantContextHolder.clear();
}
@Test
void duplicatePreviewCommandReturnsExistingJobWithoutRescanning() {
QuestionImportJobDO existing = job(101L, "PREVIEW_READY");
existing.setPreviewRequestHash(null);
when(jobMapper.selectByPreviewCommand(10L, "preview-1")).thenReturn(existing);
QuestionImportJobProjection result = service.requestPreview(new PreviewQuestionImportCommand(
"preview-1", "object/10/questions.csv", "questions.csv", "text/csv", 128L), 7L);
assertEquals(101L, result.id());
verify(jobMapper, never()).insert((QuestionImportJobDO) any(QuestionImportJobDO.class));
verifyNoInteractions(scanGateway, parser, lifecycleService);
}
@Test
void cleanScanWithoutParserProducesMetadataOnlyPreviewReady() {
service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.of(scanGateway),
Optional.empty(), lifecycleService);
QuestionImportJobDO claimed = job(101L, "PREVIEW_PENDING");
claimed.setTenantId(10L);
when(jobMapper.claimPreview(101L)).thenReturn(claimed);
when(scanGateway.scan("object/10/questions.csv")).thenReturn(ImportObjectScanGateway.ScanResult.CLEAN);
service.preview(101L);
ArgumentCaptor<QuestionImportJobDO> captor = ArgumentCaptor.forClass(QuestionImportJobDO.class);
verify(jobMapper).completePreview(captor.capture());
assertEquals("PREVIEW_READY", captor.getValue().getStatus());
assertEquals("CLEAN", captor.getValue().getScanStatus());
assertEquals("UNAVAILABLE", captor.getValue().getParserStatus());
assertNull(captor.getValue().getParsedPayload());
verifyNoInteractions(lifecycleService);
}
@Test
void unavailableScannerFailsClosedAndNeverParses() {
service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.empty(),
Optional.of(parser), lifecycleService);
when(jobMapper.claimPreview(101L)).thenReturn(job(101L, "PREVIEW_PENDING"));
ServiceException ex = assertThrows(ServiceException.class, () -> service.preview(101L));
assertEquals(QUESTION_IMPORT_SCAN_NOT_CLEAN.getCode(), ex.getCode());
verify(jobMapper).failPreview(eq(101L), eq("SCAN_UNAVAILABLE"));
verifyNoInteractions(parser, lifecycleService);
}
@Test
void executePropagatesClaimedTenantAndCreatesEveryQuestionAsDraft() {
TenantContextHolder.clear();
QuestionImportJobDO claimed = job(101L, "EXECUTE_PENDING");
claimed.setTenantId(42L);
claimed.setScanStatus("CLEAN");
claimed.setParserStatus("PARSED");
claimed.setParsedPayload("stored-preview");
when(jobMapper.claimExecute(101L)).thenReturn(claimed);
when(parser.restore("stored-preview")).thenReturn(List.of(question("One"), question("Two")));
when(lifecycleService.createDraft(any())).thenAnswer(invocation -> {
assertEquals(42L, TenantContextHolder.getRequiredTenantId());
return 100L;
});
service.execute(101L);
verify(lifecycleService, times(2)).createDraft(any());
verify(jobMapper).completeExecute(101L, 2);
assertNull(TenantContextHolder.getTenantId());
}
@Test
void executeFailsBeforeImportOutsideJavaRead() {
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
when(jobMapper.claimExecute(101L)).thenReturn(job(101L, "EXECUTE_PENDING"));
assertThrows(ServiceException.class, () -> service.execute(101L));
verifyNoInteractions(parser, lifecycleService);
}
private QuestionImportJobDO job(Long id, String status) {
QuestionImportJobDO job = new QuestionImportJobDO();
job.setId(id);
job.setTenantId(10L);
job.setActorId(7L);
job.setObjectKey("object/10/questions.csv");
job.setFileName("questions.csv");
job.setMediaType("text/csv");
job.setFileSize(128L);
job.setPreviewCommandKey("preview-1");
job.setPreviewRequestHash("hash");
job.setStatus(status);
return job;
}
private QuestionDraftCommand question(String stem) {
return new QuestionDraftCommand(stem, "choice", "easy", List.of(
new QuestionDraftCommand.QuestionDraftOption("A", "Yes", 1D),
new QuestionDraftCommand.QuestionDraftOption("B", "No", 2D)), "A", null, null);
}
}