From 520429ac6f6720a461d2ce6fdf477da1a752c035 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 19 May 2026 09:56:27 +0800 Subject: [PATCH] test(skill): end-to-end coverage for the lifecycle curator --- .../SkillControllerLifecycleTest.java | 195 +++++++++++++++ .../lifecycle/CuratorRunNotifierTest.java | 55 ++++ .../skill/lifecycle/SkillCuratorJobTest.java | 236 ++++++++++++++++++ .../lifecycle/SkillLifecycleServiceTest.java | 103 ++++++++ .../SkillUsageServiceActivityBubbleTest.java | 63 +++++ .../service/SystemSettingBoolApiTest.java | 90 +++++++ 6 files changed, 742 insertions(+) create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/lifecycle/CuratorRunNotifierTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java new file mode 100644 index 00000000..32db9f24 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java @@ -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 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> r = controller.curatorReports(); + assertEquals(1, r.getData().size()); + } + + @Test + void curatorReportReadsAKnownRun() { + when(skillCuratorReportStore.readRun("20260519-020000")).thenReturn(Map.of("runId", "20260519-020000")); + R r = controller.curatorReport("20260519-020000"); + assertEquals(200, r.getCode()); + } + + @Test + void curatorReportThrowsForUnknownRun() { + when(skillCuratorReportStore.readRun("nope")).thenReturn(null); + assertThrows(MateClawException.class, () -> controller.curatorReport("nope")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/CuratorRunNotifierTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/CuratorRunNotifierTest.java new file mode 100644 index 00000000..8e1ccb85 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/CuratorRunNotifierTest.java @@ -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)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java new file mode 100644 index 00000000..ba00a062 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java @@ -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 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 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 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 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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java index 919e5a20..f817dc80 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java @@ -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 diff --git a/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java new file mode 100644 index 00000000..0e94e902 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java @@ -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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java new file mode 100644 index 00000000..6aaffcdb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java @@ -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 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 cap = ArgumentCaptor.forClass(SystemSettingEntity.class); + verify(mapper).updateById(cap.capture()); + assertEquals("new", cap.getValue().getSettingValue()); + } +}