mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
Schema foundations for the rest of the epic. Three additions: 1. mate_conversation.archived — INT default 0. Lets a visitor "soft-close" a thread: stays on disk (history preserved, addressable, downloadable) but excluded from default /sessions listing. Pinned/archived are orthogonal; archive dominates (archived+pinned still hidden by default). 2. webchat_revoked_visitor — registry table consumed in PR 2 by the WebChatTokenRevocationService. Unique on (channel_id, visitor_id, deleted) so re-revoke is idempotent and deleted=1 un-revokes. Migration written for all three DBs (h2 IF NOT EXISTS, MySQL INFORMATION_SCHEMA guard, KingbaseES native IF NOT EXISTS) following the V147 pattern. 3. WebChatSessionView gains pinned/archived/streamStatus so the visitor- side listing surfaces the same state the admin console sees. Closes the "field exposure" gap from the epic. loadVisitorSessions gains an includeArchived flag (default false) — the default hides archived threads and excludes them from the empty-session quota, since the visitor already declared they're done with them. GET /sessions and GET /sessions/page thread an `includeArchived=true` query param through. Tests (WebChatSchemaFieldsTest, @SpringBootTest + H2 + V148): - revoked-visitor table is queryable - archived column is read/write - view exposes pinned/archived/streamStatus - archived hidden by default, visible with includeArchived=true - archived empty threads don't saturate the 5-thread quota Regression: WebChatCreateSessionTest (9/9), WebChatSessionManagementTest (5/5) — both updated for the new includeArchived param. Part of epic #355.
205 lines
9.2 KiB
Java
205 lines
9.2 KiB
Java
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.WebChatCreateSessionRequest;
|
|
import vip.mate.common.result.R;
|
|
import vip.mate.workspace.conversation.ConversationService;
|
|
|
|
import java.util.Map;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
/**
|
|
* End-to-end verification of {@code POST /api/v1/channels/webchat/sessions}
|
|
* (explicit empty-session creation) against a booted context + real H2 with
|
|
* migrations (incl. V147 {@code webchat_session_id}) applied.
|
|
* <p>
|
|
* Covers the four behaviors promised in issue #351:
|
|
* <ol>
|
|
* <li>happy path inserts an empty thread and returns sessionId/conversationId/
|
|
* visitorToken;</li>
|
|
* <li>a caller-supplied title is persisted and survives the first /stream
|
|
* user message (saveMessage's "title-derive" guard must not fire);</li>
|
|
* <li>re-creating with a colliding sessionId is idempotent — 200, no title
|
|
* clobber;</li>
|
|
* <li>the empty-session quota (≤ 5) is enforced with a clear 409.</li>
|
|
* </ol>
|
|
*/
|
|
@SpringBootTest(
|
|
classes = MateClawApplication.class,
|
|
webEnvironment = SpringBootTest.WebEnvironment.NONE
|
|
)
|
|
@TestPropertySource(properties = {
|
|
"spring.datasource.url=jdbc:h2:mem:webchat_create_sess_${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 WebChatCreateSessionTest {
|
|
|
|
private static final String SECRET = "webchat-it-secret-0123456789";
|
|
private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1"
|
|
private static final long CHANNEL_ID = 9_147_101L;
|
|
private static final long AGENT_ID = 9_147_1011L;
|
|
|
|
@Autowired private WebChatController controller;
|
|
@Autowired private ConversationService conversationService;
|
|
@Autowired private JdbcTemplate jdbc;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID);
|
|
jdbc.update("DELETE FROM mate_agent WHERE id = ?", 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-test-agent', 'react', '', 10, TRUE, 1, " +
|
|
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
|
|
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 + "\"}");
|
|
}
|
|
|
|
private WebChatCreateSessionRequest req(String visitorId, String sessionId, String title) {
|
|
WebChatCreateSessionRequest r = new WebChatCreateSessionRequest();
|
|
r.setVisitorId(visitorId);
|
|
r.setSessionId(sessionId);
|
|
r.setTitle(title);
|
|
return r;
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("createSession inserts an empty thread and returns all required fields")
|
|
void createsEmptySession() {
|
|
R<Map<String, Object>> r = controller.createSession(API_KEY, req("visitorA", "s1", null));
|
|
assertThat(r.getCode()).isEqualTo(200);
|
|
Map<String, Object> data = r.getData();
|
|
assertThat(data.get("sessionId")).isEqualTo("s1");
|
|
assertThat(data.get("conversationId"))
|
|
.isEqualTo(WebChatController.deriveConversationId(API_KEY, "visitorA", "s1"));
|
|
assertThat(data.get("visitorToken"))
|
|
.isEqualTo(WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorA"));
|
|
// No title supplied → default placeholder, will be derived from first user message later.
|
|
assertThat(data.get("title")).isEqualTo("新对话");
|
|
assertThat(data.get("createTime")).isNotNull();
|
|
|
|
// Row actually persisted with message_count = 0.
|
|
Integer count = jdbc.queryForObject(
|
|
"SELECT message_count FROM mate_conversation WHERE conversation_id = ?",
|
|
Integer.class, data.get("conversationId"));
|
|
assertThat(count).isZero();
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("caller-supplied title survives the first /stream user message")
|
|
void titleSurvivesFirstMessage() {
|
|
String cid = (String) controller
|
|
.createSession(API_KEY, req("visitorB", "s-title", "Quarterly Report"))
|
|
.getData().get("conversationId");
|
|
|
|
// Simulate /stream saving the first user message.
|
|
conversationService.saveMessage(cid, "user", "随便说点什么,看看会不会把 title 覆盖掉");
|
|
|
|
String persisted = jdbc.queryForObject(
|
|
"SELECT title FROM mate_conversation WHERE conversation_id = ?",
|
|
String.class, cid);
|
|
assertThat(persisted).isEqualTo("Quarterly Report");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("default-title thread still derives its title from the first user message")
|
|
void defaultTitleIsDerivedFromFirstMessage() {
|
|
String cid = (String) controller
|
|
.createSession(API_KEY, req("visitorC", "s-default", null))
|
|
.getData().get("conversationId");
|
|
|
|
conversationService.saveMessage(cid, "user", "今天天气不错");
|
|
|
|
String persisted = jdbc.queryForObject(
|
|
"SELECT title FROM mate_conversation WHERE conversation_id = ?",
|
|
String.class, cid);
|
|
assertThat(persisted).isEqualTo("今天天气不错");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("re-create with colliding sessionId is idempotent — no title clobber")
|
|
void isIdempotentOnCollision() {
|
|
// First call creates with a caller title.
|
|
controller.createSession(API_KEY, req("visitorD", "s-collide", "OriginalTitle"));
|
|
|
|
// Second call tries to re-create the same sessionId with a different title.
|
|
R<Map<String, Object>> r = controller
|
|
.createSession(API_KEY, req("visitorD", "s-collide", "AttemptedOverride"));
|
|
assertThat(r.getCode()).isEqualTo(200);
|
|
assertThat(r.getData().get("title")).isEqualTo("OriginalTitle");
|
|
|
|
String persisted = jdbc.queryForObject(
|
|
"SELECT title FROM mate_conversation WHERE conversation_id = ?",
|
|
String.class, r.getData().get("conversationId"));
|
|
assertThat(persisted).isEqualTo("OriginalTitle");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("empty-session quota (≤ 5) is enforced with a 409")
|
|
void enforcesQuota() {
|
|
// Pre-seed 5 empty threads directly through the service (bypasses the controller
|
|
// quota so we can verify the controller is the gate, not the service).
|
|
String owner = WebChatController.webchatUsername("visitorE");
|
|
for (int i = 1; i <= 5; i++) {
|
|
conversationService.getOrCreateWebchatConversation(
|
|
WebChatController.deriveConversationId(API_KEY, "visitorE", "seed" + i),
|
|
null, owner, 1L, "seed" + i);
|
|
}
|
|
|
|
R<Map<String, Object>> r = controller.createSession(API_KEY, req("visitorE", "s-new", null));
|
|
assertThat(r.getCode()).isEqualTo(409);
|
|
assertThat(r.getMsg()).contains("未活跃会话数已达上限");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("bad API Key → 401")
|
|
void rejectsBadApiKey() {
|
|
R<Map<String, Object>> r = controller.createSession("bogus-key", req("visitorF", "s1", null));
|
|
assertThat(r.getCode()).isEqualTo(401);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("illegal sessionId charset → 400")
|
|
void rejectsIllegalSessionId() {
|
|
R<Map<String, Object>> r = controller
|
|
.createSession(API_KEY, req("visitorG", "has space", null));
|
|
assertThat(r.getCode()).isEqualTo(400);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("illegal title length (>100) → 400")
|
|
void rejectsOverlongTitle() {
|
|
R<Map<String, Object>> r = controller
|
|
.createSession(API_KEY, req("visitorH", "s1", "x".repeat(101)));
|
|
assertThat(r.getCode()).isEqualTo(400);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("once a session is created, listSessions sees it (with the recovered sessionId)")
|
|
@SuppressWarnings("unchecked")
|
|
void createdSessionIsListable() {
|
|
controller.createSession(API_KEY, req("visitorI", "s-listed", null));
|
|
|
|
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorI");
|
|
R<?> r = controller.listSessions(API_KEY, token, "visitorI", false);
|
|
assertThat(r.getCode()).isEqualTo(200);
|
|
assertThat(((java.util.List<WebChatController.WebChatSessionView>) (Object) r.getData()))
|
|
.extracting(WebChatController.WebChatSessionView::getSessionId)
|
|
.contains("s-listed");
|
|
}
|
|
}
|