mateclaw/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java

347 lines
14 KiB
Java

package vip.mate.wiki.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import vip.mate.agent.binding.model.AgentWikiKbBinding;
import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link WikiKnowledgeBaseService#resolvePrimaryKb(Long)}.
*
* <p>{@code listByAgentId} returns both the agent's own KBs and shared
* (agent-less) KBs, ordered by {@code update_time} descending. A naive
* {@code get(0)} pick therefore hands back whichever KB was touched most
* recently — which can be an unrelated shared KB. {@code resolvePrimaryKb}
* must still return the KB actually bound to the agent.
*/
class WikiKnowledgeBaseServiceTest {
private final WikiKnowledgeBaseMapper kbMapper = mock(WikiKnowledgeBaseMapper.class);
private final AgentMapper agentMapper = mock(AgentMapper.class);
private final WikiKnowledgeBaseService service = new WikiKnowledgeBaseService(
kbMapper, null, null, null, null, null, agentMapper);
private static WikiKnowledgeBaseEntity kb(long id, Long agentId) {
return kb(id, agentId, null);
}
private static WikiKnowledgeBaseEntity kb(long id, Long agentId, String name) {
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
entity.setId(id);
entity.setAgentId(agentId);
entity.setName(name);
return entity;
}
private static WikiKnowledgeBaseEntity kb(long id, Long agentId, Long workspaceId, String name) {
WikiKnowledgeBaseEntity entity = kb(id, agentId, name);
entity.setWorkspaceId(workspaceId);
return entity;
}
private static AgentEntity agent(long id, Long workspaceId, Long primaryKbId) {
AgentEntity entity = new AgentEntity();
entity.setId(id);
entity.setWorkspaceId(workspaceId);
entity.setPrimaryKbId(primaryKbId);
return entity;
}
@Test
@DisplayName("prefers the agent's bound KB even when a shared KB was updated more recently")
void prefersBoundKbOverNewerSharedKb() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L));
// listByAgentId order is update_time DESC: two shared KBs precede the bound one.
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null, 1L, "Shared"),
kb(800L, 8L, 1L, "Legacy Other"),
kb(100L, null, 1L, "Primary")));
assertThat(service.resolvePrimaryKb(7L)).isNotNull();
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(100L);
}
@Test
@DisplayName("falls back to the most recent shared KB when the agent has no bound KB")
void fallsBackToSharedKbWhenNoneBound() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, 8L, 1L, "Most Recent"),
kb(800L, null, 1L, "Older")));
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(900L);
}
@Test
@DisplayName("two agents can share the same primary KB")
void twoAgentsCanSharePrimaryKb() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L));
when(agentMapper.selectById(8L)).thenReturn(agent(8L, 1L, 100L));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, null, 1L, "Shared Primary"),
kb(900L, null, 1L, "Fallback")));
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(100L);
assertThat(service.resolvePrimaryKb(8L).getId()).isEqualTo(100L);
}
@Test
@DisplayName("primary KB pointing outside the agent workspace falls back")
void primaryKbOutsideWorkspaceFallsBack() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 200L));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null, 1L, "Workspace Fallback"),
kb(200L, null, 2L, "Wrong Workspace")));
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(900L);
}
@Test
@DisplayName("returns null when the agent can reach no knowledge base")
void returnsNullWhenNoKb() {
when(kbMapper.selectList(any())).thenReturn(List.of());
assertThat(service.resolvePrimaryKb(7L)).isNull();
}
// ==================== findByName ====================
//
// The wiki tools added a kbName parameter so the LLM can target a
// non-primary KB. findByName is the resolution layer behind that
// parameter — it must restrict the match to KBs visible to the agent
// and refuse to silently fall through to the primary on a miss, so a
// bad pick surfaces as a clear "use wiki_list_kbs" hint instead of
// routing to the wrong KB.
@Test
@DisplayName("findByName matches by exact name within the agent's visible set")
void findByNameMatchesVisibleKb() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null, "Shared Docs"),
kb(100L, 7L, "Agent Personal KB")));
WikiKnowledgeBaseEntity hit = service.findByName(7L, "Agent Personal KB");
assertThat(hit).isNotNull();
assertThat(hit.getId()).isEqualTo(100L);
WikiKnowledgeBaseEntity sharedHit = service.findByName(7L, "Shared Docs");
assertThat(sharedHit).isNotNull();
assertThat(sharedHit.getId()).isEqualTo(900L);
}
@Test
@DisplayName("findByName returns null when name does not match any visible KB")
void findByNameMissReturnsNull() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null, "Shared Docs"),
kb(100L, 7L, "Agent Personal KB")));
assertThat(service.findByName(7L, "Nonexistent KB")).isNull();
}
@Test
@DisplayName("findByName is case-sensitive — LLM must copy the name verbatim")
void findByNameIsCaseSensitive() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Agent Personal KB")));
assertThat(service.findByName(7L, "agent personal kb")).isNull();
assertThat(service.findByName(7L, "Agent Personal KB")).isNotNull();
}
@Test
@DisplayName("findByName returns null for blank / null kbName")
void findByNameBlankReturnsNull() {
assertThat(service.findByName(7L, null)).isNull();
assertThat(service.findByName(7L, "")).isNull();
assertThat(service.findByName(7L, " ")).isNull();
}
// ==================== findByName ambiguity + findAllByName + findVisibleById ====================
//
// mate_wiki_knowledge_base has no unique constraint on name (one DB row
// per workspace + (name nullable + duplicates allowed) by design), so
// a non-blank kbName can match more than one visible KB. The single-
// result findByName must not silently pick "the first one" in that
// case — callers route through findAllByName + an ambiguous-error
// surface so the LLM is forced to disambiguate by kbId.
@Test
@DisplayName("findByName returns null when more than one visible KB shares the name")
void findByNameAmbiguousReturnsNull() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Docs"),
kb(900L, null, "Docs")));
assertThat(service.findByName(7L, "Docs"))
.as("ambiguous matches collapse to null — caller must use findAllByName")
.isNull();
}
@Test
@DisplayName("findAllByName returns every visible KB sharing the name")
void findAllByNameReturnsAllMatches() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Docs"),
kb(900L, null, "Docs"),
kb(800L, null, "Other")));
List<WikiKnowledgeBaseEntity> hits = service.findAllByName(7L, "Docs");
assertThat(hits).hasSize(2);
assertThat(hits).extracting(WikiKnowledgeBaseEntity::getId).containsExactly(100L, 900L);
}
@Test
@DisplayName("findAllByName returns empty for blank kbName")
void findAllByNameBlankReturnsEmpty() {
assertThat(service.findAllByName(7L, null)).isEmpty();
assertThat(service.findAllByName(7L, " ")).isEmpty();
}
@Test
@DisplayName("findVisibleById returns the KB only when it is in the agent's visibility set")
void findVisibleByIdGate() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Bound KB"),
kb(900L, null, "Shared KB"),
kb(800L, 8L, "Other Agent Primary KB")));
// Visible: returned.
assertThat(service.findVisibleById(7L, 100L)).isNotNull();
assertThat(service.findVisibleById(7L, 900L)).isNotNull();
assertThat(service.findVisibleById(7L, 800L)).isNotNull();
// Not in visibility set: deliberate fail-closed gate so an LLM
// can't pivot to an arbitrary KB by guessing an id.
assertThat(service.findVisibleById(7L, 99999L)).isNull();
assertThat(service.findVisibleById(7L, null)).isNull();
}
// ==================== Agent ↔ KB access scope (issue #261) ====================
//
// When an agent has scope rows in mate_agent_wiki_kb, listByAgentId — the
// single choke point every wiki tool reads through — must narrow the
// workspace-wide KB set to the bound subset. No rows = unrestricted, so
// every pre-scoping agent keeps its old workspace-wide visibility.
private AgentWikiKbBindingMapper bindScope(long... kbIds) {
AgentWikiKbBindingMapper mapper = mock(AgentWikiKbBindingMapper.class);
java.util.List<AgentWikiKbBinding> rows = new java.util.ArrayList<>();
for (long kbId : kbIds) {
AgentWikiKbBinding row = new AgentWikiKbBinding();
row.setKbId(kbId);
row.setEnabled(true);
rows.add(row);
}
when(mapper.selectList(any())).thenReturn(rows);
ReflectionTestUtils.setField(service, "kbBindingMapper", mapper);
return mapper;
}
@Test
@DisplayName("scope narrows listByAgentId to the bound KBs only")
void scopeRestrictsVisibleKbs() {
bindScope(100L, 300L);
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, null, 1L, "Business KB"),
kb(200L, null, 1L, "Unrelated KB"),
kb(300L, null, 1L, "Other Business KB")));
List<WikiKnowledgeBaseEntity> visible = service.listByAgentId(7L);
assertThat(visible).extracting(WikiKnowledgeBaseEntity::getId)
.containsExactlyInAnyOrder(100L, 300L);
// The out-of-scope KB is invisible even when targeted by id directly.
assertThat(service.findVisibleById(7L, 200L)).isNull();
assertThat(service.findVisibleById(7L, 300L)).isNotNull();
}
@Test
@DisplayName("no scope rows leaves the agent unrestricted (workspace-wide)")
void noScopeMeansUnrestricted() {
bindScope(); // empty → mapper returns no rows
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, null, 1L, "KB A"),
kb(200L, null, 1L, "KB B")));
assertThat(service.listByAgentId(7L)).extracting(WikiKnowledgeBaseEntity::getId)
.containsExactlyInAnyOrder(100L, 200L);
}
@Test
@DisplayName("a stale scope row for a removed/moved KB simply drops out")
void staleScopeRowIsIntersectedAway() {
bindScope(100L, 999L); // 999 no longer in the workspace
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, null, 1L, "Live KB"),
kb(200L, null, 1L, "Unrelated KB")));
assertThat(service.listByAgentId(7L)).extracting(WikiKnowledgeBaseEntity::getId)
.containsExactly(100L);
}
@Test
@DisplayName("primary KB resolution respects the scope")
void primaryKbRespectsScope() {
bindScope(300L);
// primary points at an out-of-scope KB → falls back to a scoped one.
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, null, 1L, "Out Of Scope Primary"),
kb(300L, null, 1L, "Scoped KB")));
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(300L);
}
// ==================== wiki_disabled opt-out (issue #551) ====================
//
// The "use no knowledge base" toggle sets mate_agent.wiki_disabled=true
// and clears the binding rows. Zero rows normally means "unrestricted",
// so the flag must short-circuit BEFORE the scope fallback — otherwise
// the opted-out agent sees every workspace KB again and the system
// prompt keeps injecting wiki content.
@Test
@DisplayName("wiki_disabled hides every KB: list, primary, and by-id lookups all come back empty")
void wikiDisabledHidesAllKbs() {
AgentEntity optedOut = agent(7L, 1L, 100L);
optedOut.setWikiDisabled(true);
when(agentMapper.selectById(7L)).thenReturn(optedOut);
assertThat(service.listByAgentId(7L)).isEmpty();
assertThat(service.resolvePrimaryKb(7L)).isNull();
assertThat(service.findVisibleById(7L, 100L)).isNull();
verify(kbMapper, never()).selectList(any());
}
@Test
@DisplayName("wiki_disabled wins over leftover binding rows")
void wikiDisabledWinsOverLeftoverBindings() {
bindScope(100L);
AgentEntity optedOut = agent(7L, 1L, 100L);
optedOut.setWikiDisabled(true);
when(agentMapper.selectById(7L)).thenReturn(optedOut);
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, null, 1L, "Bound KB")));
assertThat(service.listByAgentId(7L)).isEmpty();
}
}