feat(webchat): expose agent-bound wiki pages to API-Key callers

Add GET /api/v1/channels/webchat/wiki/pages mirroring /skills, so
downstream integrators can build a [[slug]] picker UI that points the
LLM at specific wiki pages. The picker token format is the universal
Obsidian/Wikipedia wikilink convention; the LLM consumes [[slug]] via
the existing wiki_read_page(slug=...) tool, so no agent-runtime changes
are needed.

- AgentBindingResolver.getBoundKbIds(agentId): three-state mirror of
  getBoundSkillIds. null = no rows (fall through to workspace-wide KBs),
  Set.of() = explicitly scoped to zero KBs, non-empty = explicit scope.
- WebChatController.listWikiPages: API Key + visitorToken auth chain,
  agentId workspace anti-escalation, visibility excludes pageType=
  synthesis (LLM intermediate artifacts), 100-page cap forces keyword
  filter, response carries only display-level metadata.
- WebChatWikiPageView DTO: kbId/kbName/slug/title/summary/pageType;
  content/embedding/sourceRawIds deliberately stay admin-console-only.
- WikiTool.wiki_read_page @Tool description: document the [[slug]]
  convention so the LLM treats each token as a wiki-page reference.
- WebChatWikiPageListTest: 8 cases covering happy path, keyword filter,
  synthesis exclusion, anti-escalation, auth failures, cap behavior,
  and the no-binding → workspace-wide fallback.

Closes #381.
This commit is contained in:
倪程伟 2026-06-19 11:30:25 +08:00 committed by matevip
parent a5e7060045
commit f6156f6093
5 changed files with 485 additions and 2 deletions

View File

@ -975,6 +975,32 @@ public class AgentBindingService implements AgentBindingResolver {
.orderByAsc(AgentWikiKbBinding::getCreateTime));
}
/**
* Effective KB ids the agent may see. Three states (mirror
* {@link #getBoundSkillIds}):
*
* <ul>
* <li>{@code null} no binding rows. Caller treats this as "no
* agent-level restriction; inherit every KB in the agent's
* workspace" (the default wiki-tool behavior).</li>
* <li>{@code Set.of()} rows exist but none are {@code enabled=true}.
* Caller treats this as "explicitly scoped to zero KBs".</li>
* <li>non-empty set the explicit allowlist.</li>
* </ul>
*/
@Override
public Set<Long> getBoundKbIds(Long agentId) {
List<AgentWikiKbBinding> bindings = listKbBindings(agentId);
if (bindings.isEmpty()) {
return null;
}
return bindings.stream()
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
.map(AgentWikiKbBinding::getKbId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
/**
* Replace the agent's KB access scope. An empty / null list clears the
* scope, returning the agent to workspace-wide (unrestricted) access.

View File

@ -77,6 +77,8 @@ public class WebChatController {
private final vip.mate.audit.service.AuditEventService auditService;
private final vip.mate.llm.routing.AgentBindingResolver agentBindingResolver;
private final vip.mate.skill.repository.SkillMapper skillMapper;
private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper;
private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper;
/** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */
static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L;
@ -374,11 +376,168 @@ public class WebChatController {
.toList());
}
/**
* 列出访客可见的 wiki 页面供下游自建`[[slug]]` 引用 pickerUI
* <p>
* 鉴权链跟 {@link #listSkills} 一致API Key 解析 channel + visitorToken
* HMAC 校验{@code agentId} 可选缺省回落到 channel 绑定的 agent
* 必须属于该 channel workspace沿用 {@code /stream} 的反越权路径
* <p>
* 可见范围 = agent 绑定的 KB无显式绑定时回落到 workspace 内全部 KB
* 下的所有 page排除 {@code pageType=synthesis}LLM 中间产物
* 上限 {@value WEBCHAT_WIKI_PICKER_MAX_PAGES}超出时强制要求 {@code keyword}
* <p>
* 出参仅暴露展示级元数据slug / title / summary / pageType / kbId /
* kbName不包含正文embeddingsourceRawIds 等内部字段
*/
@Operation(summary = "列出访客可见 wiki 页面(供 [[slug]] picker UI")
@GetMapping("/wiki/pages")
public R<List<WebChatWikiPageView>> listWikiPages(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam(required = false) Long agentId,
@RequestParam String visitorId,
@RequestParam(required = false) String keyword) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
}
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
// Resolve the target agent same anti-escalation rule as /stream and
// /skills: an explicit agentId must belong to the channel's workspace.
Long resolvedAgentId = channel.getAgentId();
if (agentId != null) {
var requested = agentService.getAgent(agentId);
if (requested == null) {
return R.fail(404, "Requested agent not found");
}
if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null
&& !channel.getWorkspaceId().equals(requested.getWorkspaceId())) {
return R.fail(403, "Requested agent does not belong to this channel's workspace");
}
resolvedAgentId = agentId;
}
if (resolvedAgentId == null) {
return R.ok(List.of());
}
// Resolve the KB scope: null = workspace-wide (every KB in the agent's
// workspace); non-empty set = explicit allowlist. Set.of() (rows exist
// but none enabled) means "agent is explicitly scoped to zero KBs"
// surface as empty so the picker shows nothing rather than falling
// through to workspace-wide.
java.util.Set<Long> boundKbIds = agentBindingResolver.getBoundKbIds(resolvedAgentId);
Long workspaceId = channel.getWorkspaceId();
java.util.Set<Long> effectiveKbIds;
if (boundKbIds != null) {
if (boundKbIds.isEmpty()) {
return R.ok(List.of());
}
effectiveKbIds = boundKbIds;
} else {
// No explicit binding fall back to every KB in the channel's
// workspace. Matches the wiki-tool behavior (an unscoped agent
// sees workspace-wide KBs).
effectiveKbIds = wikiKbMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiKnowledgeBaseEntity>()
.eq(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getWorkspaceId,
workspaceId == null ? 1L : workspaceId))
.stream()
.map(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId)
.filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toSet());
if (effectiveKbIds.isEmpty()) {
return R.ok(List.of());
}
}
// Build the page query: KB scope + exclude hidden pageTypes + optional
// keyword filter on slug/title. Use a single LIKE with OR so a visitor
// typing "auth" matches either "auth-design" (slug) or "Auth Design" (title).
String trimmedKeyword = keyword == null ? null : keyword.trim();
boolean hasKeyword = trimmedKeyword != null && !trimmedKeyword.isEmpty();
// Cap check: if no keyword and total candidate count exceeds the cap,
// refuse the caller must narrow with a keyword. Counting before
// selecting avoids materializing a huge list into memory.
// NOTE: the count wrapper is built WITHOUT ORDER BY H2 in MySQL mode
// rejects "ORDER BY slug" on a COUNT(*) query (column must appear in
// GROUP BY). The select wrapper below adds ORDER BY slug.
if (!hasKeyword) {
long total = wikiPageMapper.selectCount(buildWikiPageFilterWrapper(effectiveKbIds, null));
if (total > WEBCHAT_WIKI_PICKER_MAX_PAGES) {
return R.fail(422, "Wiki page count (" + total
+ ") exceeds picker cap (" + WEBCHAT_WIKI_PICKER_MAX_PAGES
+ "); please provide a 'keyword' query parameter to narrow.");
}
}
List<vip.mate.wiki.model.WikiPageEntity> pages = wikiPageMapper.selectList(
buildWikiPageFilterWrapper(effectiveKbIds, hasKeyword ? trimmedKeyword : null)
.orderByAsc(vip.mate.wiki.model.WikiPageEntity::getSlug));
if (pages.isEmpty()) {
return R.ok(List.of());
}
// Hydrate KB names so the picker UI can show "<kb>: <page title>" and
// the LLM can disambiguate when two KBs share a slug.
java.util.Map<Long, String> kbNames = wikiKbMapper.selectBatchIds(effectiveKbIds).stream()
.collect(java.util.stream.Collectors.toMap(
vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId,
kb -> kb.getName() != null ? kb.getName() : "",
(a, b) -> a));
return R.ok(pages.stream()
.map(p -> WebChatWikiPageView.from(p, kbNames.get(p.getKbId())))
.toList());
}
/**
* Build the WHERE-clause portion of the wiki-page picker query: KB scope +
* pageType-not-in-hidden + optional keyword LIKE on slug / title.
* Returned without ORDER BY so the caller can layer sorting (select) or
* nothing (count) on top H2 in MySQL mode rejects ORDER BY on COUNT(*).
*/
private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiPageEntity>
buildWikiPageFilterWrapper(java.util.Set<Long> kbIds, String keyword) {
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiPageEntity> w =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiPageEntity>()
.in(vip.mate.wiki.model.WikiPageEntity::getKbId, kbIds)
.notIn(vip.mate.wiki.model.WikiPageEntity::getPageType, WEBCHAT_WIKI_HIDDEN_PAGE_TYPES);
if (keyword != null && !keyword.isEmpty()) {
String like = "%" + keyword + "%";
w.and(qq -> qq.like(vip.mate.wiki.model.WikiPageEntity::getSlug, like)
.or().like(vip.mate.wiki.model.WikiPageEntity::getTitle, like));
}
return w;
}
/** Cap on how many empty (message_count = 0) threads one visitor may hold on a
* channel at once. Guards against pathologic clients churning placeholder
* sessions without ever sending a message. */
private static final int MAX_EMPTY_SESSIONS_PER_VISITOR = 5;
/**
* Upper bound on the page count the wiki-page picker will return without a
* keyword filter. Beyond this the caller MUST supply {@code keyword}
* returning 500 pages to a visitor picker is both bandwidth-wasteful and
* unusable as a UI. Mirrors the slash-skill picker's "small list, search
* when too big" stance.
*/
private static final int WEBCHAT_WIKI_PICKER_MAX_PAGES = 100;
/**
* Page types hidden from the visitor picker. {@code synthesis} pages are
* LLM-generated intermediate artifacts (compiled on demand by
* {@code wiki_compile_page}); they aren't curated source material and
* surfacing them to a downstream visitor is noise. Entity / concept /
* source pages are human-readable references the visitor can meaningfully
* point the LLM at.
*/
private static final java.util.Set<String> WEBCHAT_WIKI_HIDDEN_PAGE_TYPES = java.util.Set.of("synthesis");
/**
* 显式创建一条访客会话线程空会话
* <p>
@ -1478,6 +1637,38 @@ public class WebChatController {
}
}
/**
* Display-level projection of a wiki page for the visitor-facing
* {@code [[slug]]} picker. Carries the slug the LLM consumes (via
* {@code wiki_read_page(slug=)}), the human-readable title/summary for
* picker UI, and the KB id + name for disambiguation when an agent is
* scoped to multiple KBs that may share a slug. Deliberately omits
* content / embedding / sourceRawIds / outgoingLinks those stay
* admin-console-only.
*/
@lombok.Data
public static class WebChatWikiPageView {
private Long kbId;
private String kbName;
/** Immutable slug — what the picker splices into the {@code [[slug]]} token; the LLM consumes it as {@code wiki_read_page(slug=…)}. */
private String slug;
private String title;
private String summary;
/** {@code entity} / {@code concept} / {@code source} / ... — never {@code synthesis} (filtered out upstream). Useful for picker grouping/icons. */
private String pageType;
static WebChatWikiPageView from(vip.mate.wiki.model.WikiPageEntity p, String kbName) {
WebChatWikiPageView v = new WebChatWikiPageView();
v.kbId = p.getKbId();
v.kbName = kbName;
v.slug = p.getSlug();
v.title = p.getTitle();
v.summary = p.getSummary();
v.pageType = p.getPageType();
return v;
}
}
/** Body for {@code POST /sessions} — explicitly create an empty thread. */
@lombok.Data
public static class WebChatCreateSessionRequest {

View File

@ -4,8 +4,9 @@ import java.util.List;
import java.util.Set;
/**
* Read access to an agent's skill / provider bindings, as needed by
* {@link ProviderRouter} for capability-aware routing.
* Read access to an agent's skill / provider / wiki-kb bindings, as needed by
* {@link ProviderRouter} for capability-aware routing and by webchat
* endpoints that need to enumerate an agent's visible catalog.
*
* <p>Declared in the {@code llm} layer so the routing code depends only on
* this abstraction. The {@code agent} layer supplies the implementation,
@ -23,4 +24,14 @@ public interface AgentBindingResolver {
* Provider ids the agent prefers, in priority order; empty when none.
*/
List<String> getPreferredProviderIds(Long agentId);
/**
* Wiki knowledge-base ids bound to the agent, or {@code null} when the
* agent has no explicit KB scope (meaning "workspace-wide — every KB
* in the agent's workspace is visible"). Mirrors the three-state
* contract of {@link #getBoundSkillIds}: {@code null} = inherit
* default, {@code Set.of()} = explicitly scoped to nothing, non-empty
* = the explicit allowlist.
*/
Set<Long> getBoundKbIds(Long agentId);
}

View File

@ -178,6 +178,10 @@ public class WikiTool {
Use sectionHeading to read only one section by its heading text.
The result includes a "sourceFiles" field listing the source documents this page was derived from.
When using content from this page in your answer, cite the page title and source files.
Convention: a user message containing `[[<slug>]]` (e.g. `参考知识库页面 [[auth-design]]: ...`)
is a wiki-page reference inserted by the chat picker. Treat each `[[slug]]` as a request to
consult that page first call this tool with the bare slug before answering.
""")
public String wiki_read_page(
@ToolParam(description = "Agent ID") Long agentId,

View File

@ -0,0 +1,251 @@
package vip.mate.channel.webchat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.channel.webchat.WebChatController.WebChatWikiPageView;
import vip.mate.common.result.R;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end coverage for {@code GET /api/v1/channels/webchat/wiki/pages}
* the visitor-facing wiki page catalogue that downstream integrators use to
* build a {@code [[slug]]} picker UI. Mirrors {@link WebChatSkillListTest}:
* verifies the auth chain (API Key + visitorToken HMAC), the agent workspace
* anti-escalation guard, the bound-KB scope, the {@code synthesis} exclusion,
* and the &gt;100-page cap that forces a keyword filter.
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:webchat_wiki_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789"
})
class WebChatWikiPageListTest {
private static final String SECRET = "webchat-it-secret-0123456789";
private static final String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_400_001L;
private static final long AGENT_ID = 9_400_011L;
private static final long OTHER_WORKSPACE_AGENT_ID = 9_400_012L;
private static final long KB_ID = 9_400_101L;
private static final long OTHER_KB_ID = 9_400_102L;
@Autowired private WebChatController controller;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void setUp() {
// Wipe bindings + pages + KBs + channel + agents in dependency-safe order.
jdbc.update("DELETE FROM mate_agent_wiki_kb WHERE agent_id IN (?, ?, ?)",
AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L);
jdbc.update("DELETE FROM mate_wiki_page WHERE kb_id IN (?, ?)", KB_ID, OTHER_KB_ID);
jdbc.update("DELETE FROM mate_wiki_knowledge_base WHERE id IN (?, ?)", KB_ID, OTHER_KB_ID);
jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID);
jdbc.update("DELETE FROM mate_agent WHERE id IN (?, ?, ?)",
AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L);
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-wiki-agent', 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
AGENT_ID);
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-wiki-other-ws-agent', 'react', '', 10, TRUE, 999, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
OTHER_WORKSPACE_AGENT_ID);
jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}");
// Two KBs in workspace 1 (the channel's workspace). Bind the agent to
// KB_ID only, so OTHER_KB_ID stays out of scope unless the agent's
// binding set is cleared.
jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " +
"page_count, raw_count, workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'Main KB', 'main', 'active', 0, 0, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
KB_ID);
jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " +
"page_count, raw_count, workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'Other KB', 'other', 'active', 0, 0, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
OTHER_KB_ID);
// Bind AGENT_ID to KB_ID only. Single enabled row effective scope = {KB_ID}.
jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " +
"create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
9_400_201L, AGENT_ID, KB_ID);
// Three pages in KB_ID: entity / concept / synthesis. The synthesis
// one must be filtered out by the picker; the other two surface sorted
// by slug (beta < zeta by slug asc... actually we use 'alpha' and
// 'beta' to make the sort obvious).
insertPage(9_400_301L, KB_ID, "alpha-page", "Alpha Page", "entity");
insertPage(9_400_302L, KB_ID, "beta-page", "Beta Page", "concept");
insertPage(9_400_303L, KB_ID, "hidden-synthesis", "Synthesis (hidden)", "synthesis");
// One page in OTHER_KB_ID must NOT surface (agent bound to KB_ID only).
insertPage(9_400_304L, OTHER_KB_ID, "other-kb-page", "Other KB Page", "entity");
}
private void insertPage(long id, long kbId, String slug, String title, String pageType) {
jdbc.update("MERGE INTO mate_wiki_page (id, kb_id, slug, title, content, summary, " +
"page_type, version, last_updated_by, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, ?, ?, '', ?, ?, 1, 'test', " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
id, kbId, slug, title, title + " summary", pageType);
}
private String tokenFor(String visitorId) {
return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId);
}
@Test
@DisplayName("happy path: bound-KB pages surface sorted by slug; synthesis filtered out; other-KB excluded")
void listReturnsBoundKbPagesSortedExcludingSynthesis() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v1"), null, "v1", null);
assertThat(r.getCode()).isEqualTo(200);
List<WebChatWikiPageView> data = r.getData();
assertThat(data).hasSize(2);
assertThat(data.get(0).getSlug()).isEqualTo("alpha-page");
assertThat(data.get(0).getTitle()).isEqualTo("Alpha Page");
assertThat(data.get(0).getKbId()).isEqualTo(KB_ID);
assertThat(data.get(0).getKbName()).isEqualTo("Main KB");
assertThat(data.get(0).getPageType()).isEqualTo("entity");
assertThat(data.get(1).getSlug()).isEqualTo("beta-page");
// synthesis must NOT surface
assertThat(data).noneMatch(p -> "synthesis".equals(p.getPageType()));
// other-KB page must NOT surface (out of scope)
assertThat(data).noneMatch(p -> "other-kb-page".equals(p.getSlug()));
}
@Test
@DisplayName("keyword filters by slug OR title (case-insensitive LIKE)")
void keywordFilterMatchesSlugOrTitle() {
R<List<WebChatWikiPageView>> bySlug = controller.listWikiPages(
API_KEY, tokenFor("v2"), null, "v2", "alpha");
assertThat(bySlug.getCode()).isEqualTo(200);
assertThat(bySlug.getData()).hasSize(1);
assertThat(bySlug.getData().get(0).getSlug()).isEqualTo("alpha-page");
R<List<WebChatWikiPageView>> byTitle = controller.listWikiPages(
API_KEY, tokenFor("v3"), null, "v3", "Beta Page");
assertThat(byTitle.getCode()).isEqualTo(200);
assertThat(byTitle.getData()).hasSize(1);
assertThat(byTitle.getData().get(0).getSlug()).isEqualTo("beta-page");
R<List<WebChatWikiPageView>> noMatch = controller.listWikiPages(
API_KEY, tokenFor("v4"), null, "v4", "nomatch");
assertThat(noMatch.getCode()).isEqualTo(200);
assertThat(noMatch.getData()).isEmpty();
}
@Test
@DisplayName("explicit agentId matching channel workspace works")
void explicitAgentIdSameWorkspace() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v5"), AGENT_ID, "v5", null);
assertThat(r.getCode()).isEqualTo(200);
assertThat(r.getData()).hasSize(2);
}
@Test
@DisplayName("explicit agentId in a different workspace → 403 (anti-escalation)")
void explicitAgentIdDifferentWorkspace() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v6"), OTHER_WORKSPACE_AGENT_ID, "v6", null);
assertThat(r.getCode()).isEqualTo(403);
assertThat(r.getData()).isNull();
}
@Test
@DisplayName("invalid API Key → 401")
void invalidApiKey() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
"garbagekeyxyz12", tokenFor("v7"), null, "v7", null);
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("missing/invalid visitor token → 401")
void invalidVisitorToken() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, "not-a-valid-token", null, "v8", null);
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("no KB bindings → fall back to workspace-wide KB set (legacy behavior)")
void noKbBindingsFallsBackToWorkspaceWide() {
// Use a fresh agent with no mate_agent_wiki_kb rows. The channel is
// repointed to it so the default-agent path resolves it.
long lonelyAgent = 9_400_099L;
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-wiki-lonely', 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
lonelyAgent);
jdbc.update("UPDATE mate_channel SET agent_id = ? WHERE id = ?", lonelyAgent, CHANNEL_ID);
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v9"), null, "v9", null);
assertThat(r.getCode()).isEqualTo(200);
// Both workspace KBs visible: alpha/beta from KB_ID + other-kb-page
// from OTHER_KB_ID. Synthesis still filtered.
assertThat(r.getData()).hasSize(3);
assertThat(r.getData()).extracting(WebChatWikiPageView::getSlug)
.containsExactlyInAnyOrder("alpha-page", "beta-page", "other-kb-page");
}
@Test
@DisplayName(">100 pages without keyword → 422 (force narrow with keyword)")
void capWithoutKeywordReturns422() {
// Seed 101 synthetic pages (slug = cap-001 ... cap-101) into KB_ID.
// These are extra to the 3 set up in @BeforeEach; synthesis-type rows
// still get filtered, so use 'entity' to make sure they all count.
for (int i = 1; i <= 101; i++) {
insertPage(9_401_000L + i, KB_ID,
String.format("cap-%03d", i),
"Cap Page " + i,
"entity");
}
R<List<WebChatWikiPageView>> noKeyword = controller.listWikiPages(
API_KEY, tokenFor("v10"), null, "v10", null);
assertThat(noKeyword.getCode()).isEqualTo(422);
assertThat(noKeyword.getData()).isNull();
// With a keyword the cap is bypassed caller gets the filtered subset
// (here: 3 of the 101 seeded rows match "cap-001" / "cap-010" / "cap-100").
R<List<WebChatWikiPageView>> withKeyword = controller.listWikiPages(
API_KEY, tokenFor("v11"), null, "v11", "cap-001");
assertThat(withKeyword.getCode()).isEqualTo(200);
assertThat(withKeyword.getData()).isNotEmpty();
}
}