mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
test(skill): end-to-end coverage for the lifecycle curator
This commit is contained in:
parent
0383740fec
commit
520429ac6f
@ -0,0 +1,195 @@
|
||||
package vip.mate.skill.controller;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.lifecycle.ConfirmRequiredException;
|
||||
import vip.mate.skill.lifecycle.LifecycleTransition;
|
||||
import vip.mate.skill.lifecycle.SkillCuratorJob;
|
||||
import vip.mate.skill.lifecycle.SkillCuratorReport;
|
||||
import vip.mate.skill.lifecycle.SkillCuratorReportStore;
|
||||
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Covers the skill lifecycle / curator controller endpoints: pin, archive
|
||||
* (including the bound-skill 409 confirm handshake), restore, and the
|
||||
* curator control panel.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillControllerLifecycleTest {
|
||||
|
||||
private static final long SID = 100L;
|
||||
|
||||
@Mock
|
||||
private SkillService skillService;
|
||||
@Mock
|
||||
private AgentBindingService agentBindingService;
|
||||
@Mock
|
||||
private SkillLifecycleService skillLifecycleService;
|
||||
@Mock
|
||||
private SkillCuratorJob skillCuratorJob;
|
||||
@Mock
|
||||
private SkillCuratorReportStore skillCuratorReportStore;
|
||||
|
||||
private SkillController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new SkillController(
|
||||
skillService, null, null, null, null, null, null, null, null, null,
|
||||
agentBindingService, null, null,
|
||||
skillLifecycleService, skillCuratorJob, skillCuratorReportStore);
|
||||
}
|
||||
|
||||
private SkillEntity skill(String state, boolean builtin) {
|
||||
SkillEntity s = new SkillEntity();
|
||||
s.setId(SID);
|
||||
s.setName("demo-skill");
|
||||
s.setSkillType(builtin ? "builtin" : "dynamic");
|
||||
s.setBuiltin(builtin);
|
||||
s.setLifecycleState(state);
|
||||
s.setWorkspaceId(1L);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ==================== pin ====================
|
||||
|
||||
@Test
|
||||
void pinDelegatesToLifecycleService() {
|
||||
SkillEntity s = skill("active", false);
|
||||
when(skillService.getSkill(SID)).thenReturn(s);
|
||||
when(skillLifecycleService.setPinned(SID, true)).thenReturn(s);
|
||||
|
||||
R<SkillEntity> r = controller.pin(SID, new SkillController.PinRequest(true), 1L);
|
||||
|
||||
assertEquals(200, r.getCode());
|
||||
verify(skillLifecycleService).setPinned(SID, true);
|
||||
}
|
||||
|
||||
// ==================== archive ====================
|
||||
|
||||
@Test
|
||||
void archiveUnboundSkillGoesStraightThrough() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill("active", false));
|
||||
when(agentBindingService.enabledAgentsBoundToSkill(SID)).thenReturn(List.of());
|
||||
|
||||
controller.archive(SID, false, null, 1L);
|
||||
|
||||
verify(skillLifecycleService).applyManual(any(), eq(LifecycleTransition.TO_ARCHIVED),
|
||||
any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveBoundSkillWithoutForceRequiresConfirm() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill("active", false));
|
||||
when(agentBindingService.enabledAgentsBoundToSkill(SID))
|
||||
.thenReturn(List.of(new ConfirmRequiredException.AgentRow(42L, "DataAnalyst")));
|
||||
|
||||
ConfirmRequiredException ex = assertThrows(ConfirmRequiredException.class,
|
||||
() -> controller.archive(SID, false, null, 1L));
|
||||
assertEquals("BOUND_SKILL_CONFIRM_REQUIRED", ex.getCode());
|
||||
verify(skillLifecycleService, never()).applyManual(any(), any(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveBoundSkillWithForceSkipsTheConfirm() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill("active", false));
|
||||
|
||||
controller.archive(SID, true, null, 1L);
|
||||
|
||||
verify(agentBindingService, never()).enabledAgentsBoundToSkill(any());
|
||||
verify(skillLifecycleService).applyManual(any(), eq(LifecycleTransition.TO_ARCHIVED),
|
||||
any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveRejectsBuiltinSkill() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill("active", true));
|
||||
assertThrows(MateClawException.class, () -> controller.archive(SID, false, null, 1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveRejectsAlreadyArchivedSkill() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill("archived", false));
|
||||
assertThrows(MateClawException.class, () -> controller.archive(SID, false, null, 1L));
|
||||
}
|
||||
|
||||
// ==================== restore ====================
|
||||
|
||||
@Test
|
||||
void restoreDelegatesToLifecycleService() {
|
||||
SkillEntity s = skill("archived", false);
|
||||
when(skillService.getSkill(SID)).thenReturn(s);
|
||||
when(skillLifecycleService.restore(SID)).thenReturn(s);
|
||||
|
||||
controller.restore(SID, 1L);
|
||||
|
||||
verify(skillLifecycleService).restore(SID);
|
||||
}
|
||||
|
||||
// ==================== curator control panel ====================
|
||||
|
||||
@Test
|
||||
void curatorDryRunDelegatesToJob() {
|
||||
when(skillCuratorJob.dryRunNow())
|
||||
.thenReturn(SkillCuratorReport.builder().runAt(LocalDateTime.now()).build());
|
||||
controller.curatorDryRun();
|
||||
verify(skillCuratorJob).dryRunNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void curatorActivateFlipsTheFlag() {
|
||||
when(skillCuratorJob.status()).thenReturn(Map.of());
|
||||
controller.curatorActivate(true);
|
||||
verify(skillCuratorJob).activate(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void curatorPauseAndResumeToggleTheJob() {
|
||||
when(skillCuratorJob.status()).thenReturn(Map.of());
|
||||
controller.curatorPause();
|
||||
verify(skillCuratorJob).setPaused(true);
|
||||
controller.curatorResume();
|
||||
verify(skillCuratorJob).setPaused(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void curatorReportsListsRunIds() {
|
||||
when(skillCuratorReportStore.listRunIds(20)).thenReturn(List.of("20260519-020000"));
|
||||
R<List<String>> r = controller.curatorReports();
|
||||
assertEquals(1, r.getData().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void curatorReportReadsAKnownRun() {
|
||||
when(skillCuratorReportStore.readRun("20260519-020000")).thenReturn(Map.of("runId", "20260519-020000"));
|
||||
R<Object> r = controller.curatorReport("20260519-020000");
|
||||
assertEquals(200, r.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void curatorReportThrowsForUnknownRun() {
|
||||
when(skillCuratorReportStore.readRun("nope")).thenReturn(null);
|
||||
assertThrows(MateClawException.class, () -> controller.curatorReport("nope"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* Covers the run notifier: every completed sweep records a durable audit row
|
||||
* and publishes a {@link SkillCuratorRunCompletedEvent}.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CuratorRunNotifierTest {
|
||||
|
||||
@Mock
|
||||
private AuditEventService auditEventService;
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private CuratorRunNotifier notifier;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
notifier = new CuratorRunNotifier(auditEventService, eventPublisher, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onRunCompleteRecordsAuditAndPublishesEvent() {
|
||||
SkillCuratorReport report = SkillCuratorReport.builder()
|
||||
.runAt(LocalDateTime.now())
|
||||
.dryRun(false)
|
||||
.config(30, 90, "AGENT_CREATED")
|
||||
.plannedCounts(2, 1, 0)
|
||||
.appliedCounts(2, 1, 0)
|
||||
.build();
|
||||
|
||||
notifier.onRunComplete(report);
|
||||
|
||||
verify(auditEventService).record(eq("CURATOR_RUN"), eq("SKILL"),
|
||||
eq(report.getRunId()), isNull(), anyString());
|
||||
verify(eventPublisher).publishEvent(any(SkillCuratorRunCompletedEvent.class));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
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 vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Covers the daily sweep gates (enabled / paused / first-run throttle), the
|
||||
* dry-run vs applied count split, orphan reconciliation, and the status
|
||||
* payload.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillCuratorJobTest {
|
||||
|
||||
@Mock
|
||||
private SkillLifecycleService lifecycleService;
|
||||
@Mock
|
||||
private SkillMapper skillMapper;
|
||||
@Mock
|
||||
private SkillCuratorReportStore reportStore;
|
||||
@Mock
|
||||
private SystemSettingService systemSettingService;
|
||||
@Mock
|
||||
private AgentBindingService agentBindingService;
|
||||
@Mock
|
||||
private SkillWorkspaceManager workspaceManager;
|
||||
@Mock
|
||||
private CuratorRunNotifier notifier;
|
||||
|
||||
private SkillLifecycleProperties properties;
|
||||
private SkillCuratorJob job;
|
||||
|
||||
private final LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new Configuration(), ""),
|
||||
SkillEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new SkillLifecycleProperties();
|
||||
job = new SkillCuratorJob(lifecycleService, skillMapper, reportStore, properties,
|
||||
systemSettingService, agentBindingService, workspaceManager, notifier);
|
||||
}
|
||||
|
||||
private SkillEntity candidate(long id, String state, LocalDateTime lastActivity) {
|
||||
SkillEntity s = new SkillEntity();
|
||||
s.setId(id);
|
||||
s.setName("skill-" + id);
|
||||
s.setSkillType("dynamic");
|
||||
s.setBuiltin(false);
|
||||
s.setPinned(false);
|
||||
s.setLifecycleState(state);
|
||||
s.setLastActivityAt(lastActivity);
|
||||
s.setCreateTime(lastActivity);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Stub the sweep collaborators with empty reconcile + the given candidates. */
|
||||
private void stubSweep(List<SkillEntity> candidates) {
|
||||
when(reportStore.write(any())).thenAnswer(i -> i.getArgument(0));
|
||||
when(agentBindingService.skillIdsBoundToEnabledAgents()).thenReturn(Set.of());
|
||||
when(agentBindingService.blockedByBindingCandidates(any())).thenReturn(List.of());
|
||||
// reconcileOrphans queries archived rows first, loadCandidates second.
|
||||
when(skillMapper.selectList(any())).thenReturn(List.of(), candidates);
|
||||
}
|
||||
|
||||
// ==================== Gates ====================
|
||||
|
||||
@Test
|
||||
void disabledCuratorNeverSweeps() {
|
||||
properties.setEnabled(false);
|
||||
job.run();
|
||||
verify(reportStore, never()).write(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void offScopeNeverSweeps() {
|
||||
properties.setScope("OFF");
|
||||
job.run();
|
||||
verify(reportStore, never()).write(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pausedCuratorNeverSweeps() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(true);
|
||||
job.run();
|
||||
verify(reportStore, never()).write(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstObservationSeedsTimestampAndDefers() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any())).thenReturn(null);
|
||||
|
||||
job.run();
|
||||
|
||||
verify(systemSettingService).saveString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), anyString(), anyString());
|
||||
verify(reportStore, never()).write(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dryRunIsThrottledWithinTheInterval() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any()))
|
||||
.thenReturn(now.minusHours(2).toString());
|
||||
when(systemSettingService.getString(eq(SkillCuratorJob.LAST_DRY_RUN_KEY), any()))
|
||||
.thenReturn(now.minusHours(2).toString());
|
||||
|
||||
job.run();
|
||||
|
||||
verify(reportStore, never()).write(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dryRunSweepsOncePerIntervalWhenDue() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any()))
|
||||
.thenReturn(now.minusHours(30).toString());
|
||||
when(systemSettingService.getString(eq(SkillCuratorJob.LAST_DRY_RUN_KEY), any())).thenReturn(null);
|
||||
stubSweep(List.of());
|
||||
|
||||
job.run();
|
||||
|
||||
ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class);
|
||||
verify(reportStore).write(cap.capture());
|
||||
assertTrue(cap.getValue().isDryRun());
|
||||
verify(systemSettingService).saveString(eq(SkillCuratorJob.LAST_DRY_RUN_KEY), anyString(), anyString());
|
||||
}
|
||||
|
||||
// ==================== Sweep counts ====================
|
||||
|
||||
@Test
|
||||
void dryRunReportShowsPlannedButNotApplied() {
|
||||
stubSweep(List.of(candidate(1L, "active", now.minusDays(40))));
|
||||
when(lifecycleService.planTransition(any(), any())).thenReturn(LifecycleTransition.TO_STALE);
|
||||
|
||||
SkillCuratorReport report = job.dryRunNow();
|
||||
|
||||
assertTrue(report.isDryRun());
|
||||
assertEquals(1, report.getPlanned().stale());
|
||||
assertEquals(0, report.getApplied().stale());
|
||||
assertEquals(1, report.getScanned());
|
||||
verify(lifecycleService, never()).apply(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activatedSweepAppliesTransitions() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(true);
|
||||
stubSweep(List.of(candidate(1L, "active", now.minusDays(40))));
|
||||
when(lifecycleService.planTransition(any(), any())).thenReturn(LifecycleTransition.TO_STALE);
|
||||
when(lifecycleService.apply(any(), any(), any())).thenReturn(true);
|
||||
|
||||
job.run();
|
||||
|
||||
ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class);
|
||||
verify(reportStore).write(cap.capture());
|
||||
assertEquals(1, cap.getValue().getPlanned().stale());
|
||||
assertEquals(1, cap.getValue().getApplied().stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcileReactivatesArchivedRowWhoseWorkspaceReturned() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(true);
|
||||
when(reportStore.write(any())).thenAnswer(i -> i.getArgument(0));
|
||||
when(agentBindingService.skillIdsBoundToEnabledAgents()).thenReturn(Set.of());
|
||||
when(agentBindingService.blockedByBindingCandidates(any())).thenReturn(List.of());
|
||||
SkillEntity orphan = candidate(9L, "archived", now.minusDays(100));
|
||||
// 1st selectList = reconcile (archived rows); 2nd = loadCandidates.
|
||||
when(skillMapper.selectList(any())).thenReturn(List.of(orphan), List.of());
|
||||
when(workspaceManager.conventionWorkspaceExists("skill-9")).thenReturn(true);
|
||||
|
||||
job.run();
|
||||
|
||||
// reconcileOrphans flips the divergent row back via a direct update.
|
||||
verify(skillMapper).update(any(), any());
|
||||
}
|
||||
|
||||
// ==================== Status & setters ====================
|
||||
|
||||
@Test
|
||||
void statusReturnsConfigControlAndCounts() {
|
||||
when(skillMapper.selectCount(any())).thenReturn(0L);
|
||||
when(agentBindingService.blockedByBindingCandidates(any())).thenReturn(List.of());
|
||||
when(reportStore.latestRunId()).thenReturn(null);
|
||||
|
||||
Map<String, Object> status = job.status();
|
||||
|
||||
assertTrue(status.containsKey("config"));
|
||||
assertTrue(status.containsKey("control"));
|
||||
assertTrue(status.containsKey("counts"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activateAndPauseWriteSystemSettings() {
|
||||
job.activate(true);
|
||||
verify(systemSettingService).saveBool(eq(SkillCuratorJob.FIRST_RUN_KEY), eq(true), anyString());
|
||||
job.setPaused(true);
|
||||
verify(systemSettingService).saveBool(eq(SkillCuratorJob.PAUSED_KEY), eq(true), anyString());
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
@ -21,6 +22,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@ -126,6 +128,107 @@ class SkillLifecycleServiceTest {
|
||||
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyStaleSkillWithinArchiveWindowStaysPut() {
|
||||
// A skill already 'stale' and idle 50d (>= stale, < archive) yields
|
||||
// NONE — a second sweep makes no further change (idempotency).
|
||||
SkillEntity s = skill("dynamic", "stale", now.minusDays(50));
|
||||
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
|
||||
}
|
||||
|
||||
// ==================== bumpActivity / setPinned ====================
|
||||
|
||||
@Test
|
||||
void bumpActivityWritesTheActivityAnchor() {
|
||||
service.bumpActivity(7L);
|
||||
verify(skillMapper).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bumpActivityWithNullIdIsANoOp() {
|
||||
service.bumpActivity(null);
|
||||
verify(skillMapper, never()).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPinnedUpdatesTheRow() {
|
||||
SkillEntity s = skill("dynamic", "active", now.minusDays(1));
|
||||
when(skillMapper.selectById(1L)).thenReturn(s);
|
||||
service.setPinned(1L, true);
|
||||
verify(skillMapper).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPinnedThrowsWhenSkillMissing() {
|
||||
when(skillMapper.selectById(99L)).thenReturn(null);
|
||||
assertThrows(MateClawException.class, () -> service.setPinned(99L, true));
|
||||
}
|
||||
|
||||
// ==================== restore ====================
|
||||
|
||||
private SkillEntity archivedSkill() {
|
||||
SkillEntity s = skill("dynamic", "archived", now.minusDays(100));
|
||||
s.setSkillContent("---\nname: demo-skill\n---\n# body");
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreMovesWorkspaceBackAndFlipsTheRow() {
|
||||
when(skillMapper.selectById(1L)).thenReturn(archivedSkill());
|
||||
when(workspaceManager.restoreWorkspace("demo-skill"))
|
||||
.thenReturn(SkillWorkspaceManager.RestoreResult.MOVED);
|
||||
|
||||
service.restore(1L);
|
||||
|
||||
verify(skillMapper).update(any(), any());
|
||||
verify(runtimeService).refreshActiveSkills();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreDbOnlySkillFlipsRowWithoutWorkspace() {
|
||||
when(skillMapper.selectById(1L)).thenReturn(archivedSkill());
|
||||
when(workspaceManager.restoreWorkspace("demo-skill"))
|
||||
.thenReturn(SkillWorkspaceManager.RestoreResult.MISSING);
|
||||
|
||||
service.restore(1L);
|
||||
|
||||
verify(skillMapper).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreRejectsUnrecoverableSkill() {
|
||||
SkillEntity s = archivedSkill();
|
||||
s.setSkillContent(" ");
|
||||
when(skillMapper.selectById(1L)).thenReturn(s);
|
||||
when(workspaceManager.restoreWorkspace("demo-skill"))
|
||||
.thenReturn(SkillWorkspaceManager.RestoreResult.MISSING);
|
||||
|
||||
assertThrows(MateClawException.class, () -> service.restore(1L));
|
||||
verify(skillMapper, never()).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreRejectsWhenWorkspaceMoveBackFails() {
|
||||
when(skillMapper.selectById(1L)).thenReturn(archivedSkill());
|
||||
when(workspaceManager.restoreWorkspace("demo-skill"))
|
||||
.thenReturn(SkillWorkspaceManager.RestoreResult.FAILED);
|
||||
|
||||
assertThrows(MateClawException.class, () -> service.restore(1L));
|
||||
verify(skillMapper, never()).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreRejectsSkillThatIsNotArchived() {
|
||||
when(skillMapper.selectById(1L)).thenReturn(skill("dynamic", "active", now));
|
||||
assertThrows(MateClawException.class, () -> service.restore(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreThrowsWhenSkillMissing() {
|
||||
when(skillMapper.selectById(1L)).thenReturn(null);
|
||||
assertThrows(MateClawException.class, () -> service.restore(1L));
|
||||
}
|
||||
|
||||
// ==================== archive atomicity ====================
|
||||
|
||||
@Test
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
package vip.mate.skill.usage;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||
import vip.mate.skill.repository.SkillUsageStatMapper;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Verifies that recording a skill load bubbles the activity timestamp up to
|
||||
* {@code mate_skill} via the lifecycle service, so the curator's daily scan
|
||||
* sees the skill as active.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillUsageServiceActivityBubbleTest {
|
||||
|
||||
@Mock
|
||||
private SkillUsageStatMapper mapper;
|
||||
@Mock
|
||||
private SkillLifecycleService lifecycleService;
|
||||
|
||||
private SkillUsageService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new Configuration(), ""),
|
||||
SkillUsageStatEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SkillUsageService(mapper, lifecycleService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordLoadedBubblesActivityToLifecycle() {
|
||||
ResolvedSkill skill = ResolvedSkill.builder().id(7L).name("demo").build();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
service.recordLoaded(skill, 1L, "conv-1", "SKILL.md", 100);
|
||||
|
||||
verify(lifecycleService).bumpActivity(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordLoadedIgnoresNullSkill() {
|
||||
service.recordLoaded(null, 1L, "conv-1", "SKILL.md", 100);
|
||||
verify(lifecycleService, never()).bumpActivity(any());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package vip.mate.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
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 vip.mate.system.model.SystemSettingEntity;
|
||||
import vip.mate.system.repository.SystemSettingMapper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Covers the typed accessors added for the lifecycle curator:
|
||||
* getBool / saveBool / getString / saveString.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SystemSettingBoolApiTest {
|
||||
|
||||
@Mock
|
||||
private SystemSettingMapper mapper;
|
||||
|
||||
private SystemSettingService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new Configuration(), ""),
|
||||
SystemSettingEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SystemSettingService(mapper);
|
||||
}
|
||||
|
||||
private SystemSettingEntity row(String value) {
|
||||
SystemSettingEntity e = new SystemSettingEntity();
|
||||
e.setSettingKey("k");
|
||||
e.setSettingValue(value);
|
||||
return e;
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBoolReturnsDefaultWhenKeyAbsent() {
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
assertTrue(service.getBool("k", true));
|
||||
assertFalse(service.getBool("k", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBoolReadsTheStoredValue() {
|
||||
when(mapper.selectOne(any())).thenReturn(row("true"));
|
||||
assertTrue(service.getBool("k", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStringReturnsTheStoredValue() {
|
||||
when(mapper.selectOne(any())).thenReturn(row("2026-05-19T02:00:00"));
|
||||
assertEquals("2026-05-19T02:00:00", service.getString("k", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveBoolInsertsWhenKeyAbsent() {
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
service.saveBool("k", true, "desc");
|
||||
ArgumentCaptor<SystemSettingEntity> cap = ArgumentCaptor.forClass(SystemSettingEntity.class);
|
||||
verify(mapper).insert(cap.capture());
|
||||
assertEquals("true", cap.getValue().getSettingValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveStringUpdatesWhenKeyPresent() {
|
||||
when(mapper.selectOne(any())).thenReturn(row("old"));
|
||||
service.saveString("k", "new", "desc");
|
||||
ArgumentCaptor<SystemSettingEntity> cap = ArgumentCaptor.forClass(SystemSettingEntity.class);
|
||||
verify(mapper).updateById(cap.capture());
|
||||
assertEquals("new", cap.getValue().getSettingValue());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user