mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
test(avatar): verify recovery with PostgreSQL transactions
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
c48b09d3a0
commit
bc2592c978
1 changed files with 423 additions and 0 deletions
|
|
@ -0,0 +1,423 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
|
||||
import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.AvatarModerationGateway;
|
||||
import com.iflytek.skillhub.service.AvatarModerationProvider;
|
||||
import com.iflytek.skillhub.service.AvatarReviewSettingsService;
|
||||
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||
import com.iflytek.skillhub.service.SkillAvatarImageProcessor;
|
||||
import com.iflytek.skillhub.service.SkillAvatarUrls;
|
||||
import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener;
|
||||
import com.iflytek.skillhub.task.SkillAvatarMachineRecoveryTask;
|
||||
import com.iflytek.skillhub.task.SkillAvatarScopeReviewRecoveryTask;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** Real PostgreSQL recovery transactions with controlled policy, storage and external provider. */
|
||||
@DataJpaTest(showSql = false)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Testcontainers
|
||||
@Import({SkillAvatarAppService.class, SkillAvatarMachineRecoveryTask.class,
|
||||
SkillAvatarScopeReviewListener.class, SkillAvatarScopeReviewRecoveryTask.class,
|
||||
com.iflytek.skillhub.task.SkillAvatarCleanupTask.class})
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class SkillAvatarRecoveryPostgresTest {
|
||||
@Container private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void postgres(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
|
||||
registry.add("spring.flyway.enabled", () -> true);
|
||||
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
|
||||
}
|
||||
|
||||
@Autowired private SkillJpaRepository skills;
|
||||
@Autowired private NamespaceJpaRepository namespaces;
|
||||
@Autowired private SkillAvatarCandidateJpaRepository candidates;
|
||||
@Autowired private SkillAvatarAppService avatars;
|
||||
@Autowired private SkillAvatarMachineRecoveryTask machineRecovery;
|
||||
@Autowired private SkillAvatarScopeReviewRecoveryTask scopeRecovery;
|
||||
@Autowired private PlatformTransactionManager transactions;
|
||||
@Autowired private JdbcTemplate jdbc;
|
||||
@Autowired private com.iflytek.skillhub.task.SkillAvatarCleanupTask cleanup;
|
||||
@Autowired private com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository users;
|
||||
@MockBean private ObjectStorageService storage;
|
||||
@MockBean private SkillAvatarImageProcessor imageProcessor;
|
||||
@MockBean private AvatarReviewSettingsService settings;
|
||||
@MockBean private AvatarModerationGateway moderation;
|
||||
@MockBean private VisibilityChecker visibility;
|
||||
@MockBean private SkillQueryService skillQueries;
|
||||
@MockBean private AuditLogService audit;
|
||||
@MockBean private RequestIdAccessor requestIds;
|
||||
|
||||
@BeforeEach
|
||||
void providerAndStorage() {
|
||||
if (((com.iflytek.skillhub.domain.user.UserAccountRepository) users).findById("owner").isEmpty()) {
|
||||
users.saveAndFlush(new com.iflytek.skillhub.domain.user.UserAccount("owner", "owner", null, null));
|
||||
}
|
||||
byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
when(storage.getObject(anyString())).thenAnswer(ignored -> new ByteArrayInputStream(image));
|
||||
when(moderation.requireConfiguredProvider()).thenReturn("xfyun");
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.PASS);
|
||||
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryCommittedPendingUploadIsRecoveredByScheduler() {
|
||||
Long skillId = skill(false);
|
||||
byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0));
|
||||
Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", image,
|
||||
new AuditRequestContext(null, null)).candidateId();
|
||||
machineRecovery.recover();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus())
|
||||
.isEqualTo(SkillAvatarCandidate.MachineStatus.PENDING);
|
||||
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
|
||||
setTime(candidateId, "created_at", Instant.now().minusSeconds(660));
|
||||
Instant createdAt = candidates.findById(candidateId).orElseThrow().getCreatedAt();
|
||||
assertThat(createdAt).isBefore(Instant.now().minusSeconds(600));
|
||||
assertThat(abandoned(createdAt.minusSeconds(1), Instant.now())).extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId);
|
||||
assertThat(abandoned(createdAt, Instant.now())).extracting(SkillAvatarCandidate::getId).contains(candidateId);
|
||||
machineRecovery.recover();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus())
|
||||
.isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED);
|
||||
assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId);
|
||||
assertThat(readSkill(skillId).getPendingAvatarId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredLeaseIsReclaimedAndLateFirstAttemptCannotOverwriteSecond() throws Exception {
|
||||
Long skillId = skill(false);
|
||||
Long candidateId = pending(skillId);
|
||||
CountDownLatch providerEntered = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenAnswer(ignored -> {
|
||||
if (calls.incrementAndGet() == 1) {
|
||||
providerEntered.countDown();
|
||||
assertThat(releaseFirst.await(20, TimeUnit.SECONDS)).isTrue();
|
||||
return AvatarModerationProvider.Verdict.BLOCK;
|
||||
}
|
||||
return AvatarModerationProvider.Verdict.PASS;
|
||||
});
|
||||
try (var pool = Executors.newFixedThreadPool(2)) {
|
||||
var first = pool.submit(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null)));
|
||||
try {
|
||||
assertThat(providerEntered.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
var otherSession = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> {
|
||||
jdbc.execute("SET LOCAL lock_timeout = '2s'");
|
||||
return skills.findLockedById(skillId).orElseThrow().getId();
|
||||
}));
|
||||
assertThat(otherSession.get(5, TimeUnit.SECONDS)).isEqualTo(skillId);
|
||||
setTime(candidateId, "machine_lease_until", Instant.now().minusSeconds(1));
|
||||
Instant lease = candidates.findById(candidateId).orElseThrow().getMachineLeaseUntil();
|
||||
assertThat(lease).isBefore(Instant.now());
|
||||
assertThat(abandoned(Instant.now().minusSeconds(600), lease.minusSeconds(1)))
|
||||
.extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId);
|
||||
assertThat(abandoned(Instant.now().minusSeconds(600), lease))
|
||||
.extracting(SkillAvatarCandidate::getId).contains(candidateId);
|
||||
machineRecovery.recover();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(2);
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
}
|
||||
assertThat(first.get(10, TimeUnit.SECONDS).machineStatus()).isEqualTo("PASSED");
|
||||
assertThat(calls.get()).isEqualTo(2);
|
||||
}
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus())
|
||||
.isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED);
|
||||
assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingScopeListenerIsCompensatedWithoutPublishingPrivateImage() {
|
||||
Long skillId = skill(true);
|
||||
Long oldAvatarId = readSkill(skillId).getActiveAvatarId();
|
||||
when(storage.getObject("old/" + skillId)).thenThrow(new IllegalStateException("temporary storage outage"));
|
||||
scopeRecovery.recover();
|
||||
assertThat(readSkill(skillId).getPendingAvatarId()).isNull();
|
||||
assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull();
|
||||
org.mockito.Mockito.doAnswer(ignored -> new ByteArrayInputStream(new byte[] {1, 2, 3}))
|
||||
.when(storage).getObject("old/" + skillId);
|
||||
scopeRecovery.recover();
|
||||
Skill recovered = readSkill(skillId);
|
||||
assertThat(recovered.getActiveAvatarId()).isNotEqualTo(oldAvatarId);
|
||||
assertThat(recovered.getActiveAvatarAudience()).isEqualTo(SkillVisibility.PUBLIC);
|
||||
assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2);
|
||||
assertThat(candidates.findById(recovered.getActiveAvatarId()).orElseThrow().isScopeReview()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void committedScopeCandidateRecoversWithCooldownAndOwnerRetryAfterThreeAutomaticAttempts() {
|
||||
// Independent candidates: a manually repeated ERROR cannot be applied to an already active PASS.
|
||||
for (AvatarModerationProvider.Verdict manualVerdict : new AvatarModerationProvider.Verdict[] {
|
||||
AvatarModerationProvider.Verdict.ERROR, AvatarModerationProvider.Verdict.PASS}) {
|
||||
Long skillId = skill(true);
|
||||
Long candidateId = avatars.prepareScopeReview(skillId, null).candidateId();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().isScopeReview()).isTrue();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getScopeReviewVersionId()).isNull();
|
||||
assertThat(abandoned(Instant.now().plusSeconds(660), Instant.now().plusSeconds(660)))
|
||||
.extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId);
|
||||
setTime(candidateId, "created_at", Instant.now().minusSeconds(660));
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getCreatedAt()).isBefore(Instant.now().minusSeconds(600));
|
||||
org.mockito.Mockito.clearInvocations(moderation);
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.ERROR);
|
||||
scopeRecovery.recover();
|
||||
SkillAvatarCandidate first = candidates.findById(candidateId).orElseThrow();
|
||||
assertThat(first.getMachineAttemptCount()).isEqualTo(1);
|
||||
assertThat(first.getMachineNextRetryAt()).isBetween(Instant.now().plusSeconds(3500), Instant.now().plusSeconds(3601));
|
||||
assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null)))
|
||||
.isInstanceOf(DomainConflictException.class);
|
||||
scopeRecovery.recover();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(1);
|
||||
for (int attempt = 2; attempt <= 3; attempt++) {
|
||||
setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1));
|
||||
scopeRecovery.recover();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(attempt);
|
||||
}
|
||||
avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner");
|
||||
assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null)))
|
||||
.isInstanceOf(DomainConflictException.class);
|
||||
assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "stranger"))
|
||||
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class);
|
||||
org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class));
|
||||
setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1));
|
||||
scopeRecovery.recover();
|
||||
scopeRecovery.recover();
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(3);
|
||||
org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class));
|
||||
assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId);
|
||||
assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull();
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(manualVerdict);
|
||||
avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner");
|
||||
var retried = avatars.processMachine(candidateId, new AuditRequestContext(null, null));
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(4);
|
||||
org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(4)).moderate(eq("xfyun"), any(byte[].class));
|
||||
if (manualVerdict == AvatarModerationProvider.Verdict.PASS) {
|
||||
assertThat(retried.avatarUrl()).isNotNull();
|
||||
assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId);
|
||||
assertThat(readSkill(skillId).getPendingAvatarId()).isNull();
|
||||
assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner"))
|
||||
.isInstanceOf(DomainConflictException.class);
|
||||
} else {
|
||||
assertThat(retried.avatarUrl()).isNull();
|
||||
assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId);
|
||||
assertThat(candidates.findById(candidateId).orElseThrow().getMachineNextRetryAt())
|
||||
.isAfter(Instant.now().plusSeconds(3500));
|
||||
}
|
||||
assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalImageReusesOnlyMatchingProviderAndManualPolicy() {
|
||||
byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0));
|
||||
Long sourceId = skill(false);
|
||||
String sourceNamespace = namespaceSlug(sourceId);
|
||||
Long sourceCandidate = avatars.submit(sourceNamespace, "skill", "owner", image, new AuditRequestContext(null, null)).candidateId();
|
||||
avatars.processMachine(sourceCandidate, new AuditRequestContext(null, null));
|
||||
|
||||
Long sameId = skill(false);
|
||||
var same = avatars.submit(namespaceSlug(sameId), "skill", "owner", image, new AuditRequestContext(null, null));
|
||||
assertThat(same.machineStatus()).isEqualTo("PASSED");
|
||||
assertThat(same.avatarUrl()).isNotNull();
|
||||
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, true, 1, null));
|
||||
Long manualId = skill(false);
|
||||
var manual = avatars.submit(namespaceSlug(manualId), "skill", "owner", image, new AuditRequestContext(null, null));
|
||||
assertThat(manual.machineStatus()).isEqualTo("PENDING");
|
||||
assertThat(manual.avatarUrl()).isNull();
|
||||
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null));
|
||||
when(moderation.requireConfiguredProvider()).thenReturn("aliyun");
|
||||
Long otherProviderId = skill(false);
|
||||
var other = avatars.submit(namespaceSlug(otherProviderId), "skill", "owner", image, new AuditRequestContext(null, null));
|
||||
assertThat(other.machineStatus()).isEqualTo("PENDING");
|
||||
assertThat(other.avatarUrl()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedAndProviderFailureNeverExposeAnActiveAvatar() {
|
||||
for (AvatarModerationProvider.Verdict verdict : new AvatarModerationProvider.Verdict[] {
|
||||
AvatarModerationProvider.Verdict.BLOCK, AvatarModerationProvider.Verdict.ERROR}) {
|
||||
Long skillId = skill(false);
|
||||
Long candidateId = pending(skillId);
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(verdict);
|
||||
var result = avatars.processMachine(candidateId, new AuditRequestContext(null, null));
|
||||
assertThat(result.machineStatus()).isEqualTo(verdict == AvatarModerationProvider.Verdict.BLOCK ? "BLOCKED" : "ERROR");
|
||||
assertThat(result.avatarUrl()).isNull();
|
||||
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
|
||||
assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId);
|
||||
}
|
||||
Long skillId = skill(false);
|
||||
Long candidateId = pending(skillId);
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenThrow(new IllegalStateException("provider unavailable"));
|
||||
assertThat(avatars.processMachine(candidateId, new AuditRequestContext(null, null)).machineStatus()).isEqualTo("ERROR");
|
||||
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalReviewDoesNotAcceptTeamAdministratorRole() {
|
||||
Long skillId = skill(false);
|
||||
Skill skill = readSkill(skillId);
|
||||
new TransactionTemplate(transactions).executeWithoutResult(status -> {
|
||||
Namespace ns = ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(skill.getNamespaceId()).orElseThrow();
|
||||
ns.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.GLOBAL);
|
||||
});
|
||||
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null));
|
||||
when(imageProcessor.normalize(any())).thenReturn(new byte[] {5, 6});
|
||||
Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[] {5, 6}, new AuditRequestContext(null, null)).candidateId();
|
||||
assertThatThrownBy(() -> avatars.decide(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "reviewer",
|
||||
java.util.Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN),
|
||||
java.util.Set.of(), new AuditRequestContext(null, null)))
|
||||
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class);
|
||||
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
|
||||
var approved = avatars.decide(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "owner",
|
||||
java.util.Map.of(), java.util.Set.of("SUPER_ADMIN"), new AuditRequestContext(null, null));
|
||||
assertThat(approved.avatarUrl()).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void expiredImageCleanupRetriesFailureAndPreservesHistoryAndActiveObject() {
|
||||
Long skillId = skill(true);
|
||||
SkillAvatarCandidate active = candidates.findById(readSkill(skillId).getActiveAvatarId()).orElseThrow();
|
||||
Long rejectedId = new TransactionTemplate(transactions).execute(status -> {
|
||||
SkillAvatarCandidate rejected = new SkillAvatarCandidate(skillId, "owner", "rejected/" + skillId,
|
||||
"rejected-sha", SkillVisibility.PUBLIC, false, true, null);
|
||||
rejected.reject("owner", "keep this textual review history");
|
||||
return candidates.saveAndFlush(rejected).getId();
|
||||
});
|
||||
SkillAvatarCandidate rejected = candidates.findById(rejectedId).orElseThrow();
|
||||
assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().minusSeconds(1)))
|
||||
.extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId);
|
||||
assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt()))
|
||||
.extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId);
|
||||
assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().plusSeconds(1)))
|
||||
.extracting(SkillAvatarCandidate::getId).contains(rejectedId);
|
||||
assertThat(rejected.getDiscardAt()).isBetween(Instant.now().plusSeconds(30L * 86400 - 60),
|
||||
Instant.now().plusSeconds(30L * 86400 + 1));
|
||||
cleanup.removeExpiredImages();
|
||||
org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject("rejected/" + skillId);
|
||||
new TransactionTemplate(transactions).executeWithoutResult(status -> {
|
||||
SkillAvatarCandidate expired = candidates.findById(rejectedId).orElseThrow();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(expired, "discardAt", Instant.now().minusSeconds(1));
|
||||
});
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("temporary object store outage"))
|
||||
.doNothing().when(storage).deleteObject("rejected/" + skillId);
|
||||
assertThatThrownBy(cleanup::removeExpiredImages).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(candidates.findById(rejectedId).orElseThrow().getObjectKey()).isEqualTo("rejected/" + skillId);
|
||||
cleanup.removeExpiredImages();
|
||||
SkillAvatarCandidate retained = candidates.findById(rejectedId).orElseThrow();
|
||||
assertThat(retained.getObjectKey()).isNull();
|
||||
assertThat(retained.getReviewReason()).isEqualTo("keep this textual review history");
|
||||
assertThat(retained.getReviewedBy()).isEqualTo("owner");
|
||||
assertThat(retained.getStatus()).isEqualTo(SkillAvatarCandidate.Status.REJECTED);
|
||||
assertThat(candidates.findById(active.getId()).orElseThrow().getObjectKey()).isEqualTo(active.getObjectKey());
|
||||
org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject(active.getObjectKey());
|
||||
}
|
||||
|
||||
|
||||
|
||||
private java.util.List<SkillAvatarCandidate> abandoned(Instant staleBefore, Instant now) {
|
||||
return candidates.findAbandonedMachineCandidates(0L, SkillAvatarCandidate.Status.PENDING,
|
||||
SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING,
|
||||
staleBefore, now, org.springframework.data.domain.PageRequest.of(0, 100));
|
||||
}
|
||||
|
||||
// Hibernate binds these timestamp-without-zone columns in UTC; use the same calendar in JDBC fixtures.
|
||||
private void setTime(Long candidateId, String column, Instant value) {
|
||||
jdbc.update(connection -> {
|
||||
var statement = connection.prepareStatement("UPDATE skill_avatar_candidate SET " + column + " = ? WHERE id = ?");
|
||||
statement.setTimestamp(1, java.sql.Timestamp.from(value),
|
||||
java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")));
|
||||
statement.setLong(2, candidateId);
|
||||
return statement;
|
||||
});
|
||||
}
|
||||
|
||||
private Skill readSkill(Long id) {
|
||||
return ((com.iflytek.skillhub.domain.skill.SkillRepository) skills).findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
private String namespaceSlug(Long skillId) {
|
||||
return ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(readSkill(skillId).getNamespaceId()).orElseThrow().getSlug();
|
||||
}
|
||||
|
||||
private Long skill(boolean expanded) {
|
||||
return new TransactionTemplate(transactions).execute(status -> {
|
||||
String suffix = UUID.randomUUID().toString().substring(0, 8);
|
||||
Namespace ns = namespaces.saveAndFlush(new Namespace("avatar-" + suffix, "Avatar recovery", "owner"));
|
||||
Skill skill = skills.saveAndFlush(new Skill(ns.getId(), "skill", "owner", expanded ? SkillVisibility.PRIVATE : SkillVisibility.PUBLIC));
|
||||
if (expanded) {
|
||||
SkillAvatarCandidate old = new SkillAvatarCandidate(skill.getId(), "owner", "old/" + skill.getId(), "old-sha-" + suffix,
|
||||
SkillVisibility.PRIVATE, false, false, null);
|
||||
old.activate();
|
||||
candidates.saveAndFlush(old);
|
||||
skill.activateAvatar(old.getId(), SkillVisibility.PRIVATE);
|
||||
skill.setVisibility(SkillVisibility.PUBLIC);
|
||||
skills.saveAndFlush(skill);
|
||||
}
|
||||
return skill.getId();
|
||||
});
|
||||
}
|
||||
|
||||
private Long pending(Long skillId) {
|
||||
return new TransactionTemplate(transactions).execute(status -> {
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skillId, "owner", "pending/" + skillId,
|
||||
"sha-" + skillId, SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
candidates.saveAndFlush(candidate);
|
||||
Skill skill = skills.findLockedById(skillId).orElseThrow();
|
||||
skill.setPendingAvatarId(candidate.getId());
|
||||
return candidate.getId();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue