test(skill): end-to-end coverage for the lifecycle curator

This commit is contained in:
matevip 2026-05-19 09:56:27 +08:00
parent 0383740fec
commit 520429ac6f
6 changed files with 742 additions and 0 deletions

View File

@ -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"));
}
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View File

@ -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

View File

@ -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());
}
}

View File

@ -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());
}
}