mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(webchat): archive flag + revoked-visitor table + view fields (epic #355 PR 1)
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.
This commit is contained in:
parent
23c1d49241
commit
332f3339a9
@ -413,7 +413,8 @@ public class WebChatController {
|
|||||||
public R<List<WebChatSessionView>> listSessions(
|
public R<List<WebChatSessionView>> listSessions(
|
||||||
@RequestHeader("X-MC-Key") String apiKey,
|
@RequestHeader("X-MC-Key") String apiKey,
|
||||||
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
||||||
@RequestParam String visitorId) {
|
@RequestParam String visitorId,
|
||||||
|
@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
ChannelEntity channel = resolveChannel(apiKey);
|
ChannelEntity channel = resolveChannel(apiKey);
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
return R.fail(401, "Invalid API Key");
|
return R.fail(401, "Invalid API Key");
|
||||||
@ -421,7 +422,7 @@ public class WebChatController {
|
|||||||
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
|
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
|
||||||
return R.fail(401, "Invalid or missing visitor token");
|
return R.fail(401, "Invalid or missing visitor token");
|
||||||
}
|
}
|
||||||
return R.ok(loadVisitorSessions(apiKey, visitorId));
|
return R.ok(loadVisitorSessions(apiKey, visitorId, includeArchived));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -437,7 +438,8 @@ public class WebChatController {
|
|||||||
@RequestParam String visitorId,
|
@RequestParam String visitorId,
|
||||||
@RequestParam(defaultValue = "1") int page,
|
@RequestParam(defaultValue = "1") int page,
|
||||||
@RequestParam(defaultValue = "20") int size,
|
@RequestParam(defaultValue = "20") int size,
|
||||||
@RequestParam(required = false) String keyword) {
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||||
ChannelEntity channel = resolveChannel(apiKey);
|
ChannelEntity channel = resolveChannel(apiKey);
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
return R.fail(401, "Invalid API Key");
|
return R.fail(401, "Invalid API Key");
|
||||||
@ -448,7 +450,7 @@ public class WebChatController {
|
|||||||
if (page < 1) page = 1;
|
if (page < 1) page = 1;
|
||||||
if (size < 1 || size > 200) size = 20;
|
if (size < 1 || size > 200) size = 20;
|
||||||
|
|
||||||
List<WebChatSessionView> all = loadVisitorSessions(apiKey, visitorId);
|
List<WebChatSessionView> all = loadVisitorSessions(apiKey, visitorId, includeArchived);
|
||||||
if (keyword != null && !keyword.isBlank()) {
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
String kw = keyword.trim().toLowerCase(java.util.Locale.ROOT);
|
String kw = keyword.trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
all = all.stream()
|
all = all.stream()
|
||||||
@ -516,6 +518,19 @@ public class WebChatController {
|
|||||||
* for legacy rows created before that column existed.
|
* for legacy rows created before that column existed.
|
||||||
*/
|
*/
|
||||||
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId) {
|
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId) {
|
||||||
|
return loadVisitorSessions(apiKey, visitorId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overload that lets the caller opt into archived threads. By default
|
||||||
|
* (used by /sessions listing and the empty-session quota check) archived
|
||||||
|
* rows are filtered out — they still exist on disk and are addressable
|
||||||
|
* by sessionId, but don't pollute the active listing and don't count
|
||||||
|
* against the "≤ 5 empty threads" quota (the visitor already declared
|
||||||
|
* they're done with them).
|
||||||
|
*/
|
||||||
|
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId,
|
||||||
|
boolean includeArchived) {
|
||||||
String base = deriveConversationId(apiKey, visitorId, null);
|
String base = deriveConversationId(apiKey, visitorId, null);
|
||||||
String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":";
|
String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":";
|
||||||
String owner = webchatUsername(visitorId);
|
String owner = webchatUsername(visitorId);
|
||||||
@ -526,9 +541,16 @@ public class WebChatController {
|
|||||||
return conversationService.listWebchatConversations(owner).stream()
|
return conversationService.listWebchatConversations(owner).stream()
|
||||||
.filter(c -> c.getConversationId() != null
|
.filter(c -> c.getConversationId() != null
|
||||||
&& c.getConversationId().startsWith(channelPrefix))
|
&& c.getConversationId().startsWith(channelPrefix))
|
||||||
|
.filter(c -> includeArchived
|
||||||
|
|| c.getArchived() == null
|
||||||
|
|| c.getArchived() == 0)
|
||||||
.map(c -> {
|
.map(c -> {
|
||||||
String sid = recoverSessionId(c, base);
|
String sid = recoverSessionId(c, base);
|
||||||
return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount());
|
return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(),
|
||||||
|
c.getMessageCount(),
|
||||||
|
c.getPinned() != null ? c.getPinned() : 0,
|
||||||
|
c.getArchived() != null ? c.getArchived() : 0,
|
||||||
|
c.getStreamStatus() != null ? c.getStreamStatus() : "idle");
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
@ -1047,6 +1069,12 @@ public class WebChatController {
|
|||||||
private String title;
|
private String title;
|
||||||
private LocalDateTime lastActiveTime;
|
private LocalDateTime lastActiveTime;
|
||||||
private Integer messageCount;
|
private Integer messageCount;
|
||||||
|
/** 1 if the visitor pinned this thread, 0 otherwise. */
|
||||||
|
private Integer pinned;
|
||||||
|
/** 1 if the visitor archived this thread, 0 otherwise. */
|
||||||
|
private Integer archived;
|
||||||
|
/** {@code running} if a stream is in progress on this thread, else {@code idle}. */
|
||||||
|
private String streamStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body for {@code POST /sessions} — explicitly create an empty thread. */
|
/** Body for {@code POST /sessions} — explicitly create an empty thread. */
|
||||||
|
|||||||
@ -51,6 +51,16 @@ public class ConversationEntity {
|
|||||||
/** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */
|
/** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */
|
||||||
private Integer pinned;
|
private Integer pinned;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive flag (webchat): 0 = active (default), 1 = archived.
|
||||||
|
* Archived threads stay in the DB (history preserved, still addressable
|
||||||
|
* by sessionId, downloadable) but are excluded from the default
|
||||||
|
* /sessions listing. A visitor opts into seeing them via
|
||||||
|
* {@code includeArchived=true}. Archive dominates pin — an archived
|
||||||
|
* AND pinned thread is still hidden by default.
|
||||||
|
*/
|
||||||
|
private Integer archived;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider id of the model this conversation is pinned to. NULL means
|
* Provider id of the model this conversation is pinned to. NULL means
|
||||||
* "inherit" — fall back to the agent's model override, then the global
|
* "inherit" — fall back to the agent's model override, then the global
|
||||||
|
|||||||
@ -66,6 +66,7 @@ public class ConversationVO extends ConversationEntity {
|
|||||||
vo.setLastActiveTime(entity.getLastActiveTime());
|
vo.setLastActiveTime(entity.getLastActiveTime());
|
||||||
vo.setWorkspaceId(entity.getWorkspaceId());
|
vo.setWorkspaceId(entity.getWorkspaceId());
|
||||||
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
|
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
|
||||||
|
vo.setArchived(entity.getArchived() != null ? entity.getArchived() : 0);
|
||||||
vo.setModelProvider(entity.getModelProvider());
|
vo.setModelProvider(entity.getModelProvider());
|
||||||
vo.setModelName(entity.getModelName());
|
vo.setModelName(entity.getModelName());
|
||||||
vo.setWebchatSessionId(entity.getWebchatSessionId());
|
vo.setWebchatSessionId(entity.getWebchatSessionId());
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
-- V148: webchat visitor-session archive flag + visitor-token revocation registry.
|
||||||
|
--
|
||||||
|
-- 1) mate_conversation.archived — INT, 0 (default) = active, 1 = archived.
|
||||||
|
-- Lets a visitor "soft-close" a thread: it stays in the DB (history
|
||||||
|
-- preserved, downloadable, addressable by sessionId) but is excluded
|
||||||
|
-- from the default /sessions listing. Pinned/archived are orthogonal:
|
||||||
|
-- archive dominates (an archived+pinned thread is still hidden by default).
|
||||||
|
--
|
||||||
|
-- 2) webchat_revoked_visitor — registry of visitors whose visitorToken HMAC
|
||||||
|
-- is no longer accepted on management endpoints (list/messages/title/
|
||||||
|
-- delete/stop/upload/regenerate). /stream is intentionally NOT bound by
|
||||||
|
-- this: a revoked visitor can still start a fresh /stream, which mints
|
||||||
|
-- a new token; the revocation applies to the old token presented on
|
||||||
|
-- management endpoints. The (channel_id, visitor_id) pair is unique among
|
||||||
|
-- non-deleted rows so a re-revoke is idempotent; setting deleted=1
|
||||||
|
-- un-revokes.
|
||||||
|
|
||||||
|
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webchat_revoked_visitor (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
visitor_id VARCHAR(128) NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reason VARCHAR(255),
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor
|
||||||
|
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup
|
||||||
|
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
-- V148: webchat visitor-session archive flag + visitor-token revocation registry (KingbaseES / PostgreSQL).
|
||||||
|
-- See the H2 copy for full context.
|
||||||
|
|
||||||
|
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webchat_revoked_visitor (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
visitor_id VARCHAR(128) NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reason VARCHAR(255),
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor
|
||||||
|
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup
|
||||||
|
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
-- V148: webchat visitor-session archive flag + visitor-token revocation registry (MySQL).
|
||||||
|
-- See the H2 copy for full context.
|
||||||
|
|
||||||
|
-- 1) archived column — MySQL 8.0 has no ADD COLUMN IF NOT EXISTS.
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_conversation'
|
||||||
|
AND COLUMN_NAME = 'archived'
|
||||||
|
);
|
||||||
|
SET @ddl := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_conversation ADD COLUMN archived INT NOT NULL DEFAULT 0 COMMENT ''webchat: 0 = active, 1 = archived (hidden from default /sessions listing)''',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 2) revoked-visitor registry
|
||||||
|
CREATE TABLE IF NOT EXISTS webchat_revoked_visitor (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
visitor_id VARCHAR(128) NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reason VARCHAR(255),
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_webchat_revoked_visitor (channel_id, visitor_id, deleted),
|
||||||
|
KEY idx_webchat_revoked_visitor_lookup (channel_id, visitor_id, deleted)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@ -195,7 +195,7 @@ class WebChatCreateSessionTest {
|
|||||||
controller.createSession(API_KEY, req("visitorI", "s-listed", null));
|
controller.createSession(API_KEY, req("visitorI", "s-listed", null));
|
||||||
|
|
||||||
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorI");
|
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorI");
|
||||||
R<?> r = controller.listSessions(API_KEY, token, "visitorI");
|
R<?> r = controller.listSessions(API_KEY, token, "visitorI", false);
|
||||||
assertThat(r.getCode()).isEqualTo(200);
|
assertThat(r.getCode()).isEqualTo(200);
|
||||||
assertThat(((java.util.List<WebChatController.WebChatSessionView>) (Object) r.getData()))
|
assertThat(((java.util.List<WebChatController.WebChatSessionView>) (Object) r.getData()))
|
||||||
.extracting(WebChatController.WebChatSessionView::getSessionId)
|
.extracting(WebChatController.WebChatSessionView::getSessionId)
|
||||||
|
|||||||
@ -0,0 +1,173 @@
|
|||||||
|
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.channel.webchat.WebChatController.WebChatSessionView;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end verification of the V148 schema additions and the
|
||||||
|
* {@code archived} filtering / view-field exposure that rides on them:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code mate_conversation.archived} column exists and is read/write.</li>
|
||||||
|
* <li>{@code webchat_revoked_visitor} table exists (full DDL validation
|
||||||
|
* happens implicitly — Flyway would have failed to apply the migration
|
||||||
|
* otherwise; here we only verify the table is queryable).</li>
|
||||||
|
* <li>{@link WebChatSessionView} now carries {@code pinned/archived/
|
||||||
|
* streamStatus}, so the visitor-side listing surfaces the same state
|
||||||
|
* the admin console sees.</li>
|
||||||
|
* <li>{@code loadVisitorSessions} filters out archived threads by default;
|
||||||
|
* {@code includeArchived=true} opts back in.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@SpringBootTest(
|
||||||
|
classes = MateClawApplication.class,
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.NONE
|
||||||
|
)
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:webchat_schema_${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 WebChatSchemaFieldsTest {
|
||||||
|
|
||||||
|
private static final String SECRET = "webchat-it-secret-0123456789";
|
||||||
|
private static final String API_KEY = "testkey1abcdefgh";
|
||||||
|
private static final long CHANNEL_ID = 9_147_301L;
|
||||||
|
private static final long AGENT_ID = 9_147_3011L;
|
||||||
|
|
||||||
|
@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-schema-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) {
|
||||||
|
WebChatCreateSessionRequest r = new WebChatCreateSessionRequest();
|
||||||
|
r.setVisitorId(visitorId);
|
||||||
|
r.setSessionId(sessionId);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tokenFor(String visitorId) {
|
||||||
|
return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("revoked-visitor table is queryable (DDL applied)")
|
||||||
|
void revokedVisitorTableExists() {
|
||||||
|
// Insert + read back a row to prove the table + columns are live.
|
||||||
|
jdbc.update("INSERT INTO webchat_revoked_visitor (id, channel_id, visitor_id, reason) " +
|
||||||
|
"VALUES (?, ?, ?, ?)", 9991L, CHANNEL_ID, "schema-probe", "test");
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ?",
|
||||||
|
Integer.class, CHANNEL_ID, "schema-probe");
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
jdbc.update("DELETE FROM webchat_revoked_visitor WHERE id = ?", 9991L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("archived column on mate_conversation is read/write")
|
||||||
|
void archivedColumnReadWrite() {
|
||||||
|
controller.createSession(API_KEY, req("visitorArch", "s1"));
|
||||||
|
String cid = WebChatController.deriveConversationId(API_KEY, "visitorArch", "s1");
|
||||||
|
|
||||||
|
Integer before = jdbc.queryForObject(
|
||||||
|
"SELECT archived FROM mate_conversation WHERE conversation_id = ?",
|
||||||
|
Integer.class, cid);
|
||||||
|
assertThat(before).isZero();
|
||||||
|
|
||||||
|
jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid);
|
||||||
|
Integer after = jdbc.queryForObject(
|
||||||
|
"SELECT archived FROM mate_conversation WHERE conversation_id = ?",
|
||||||
|
Integer.class, cid);
|
||||||
|
assertThat(after).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("WebChatSessionView exposes pinned/archived/streamStatus")
|
||||||
|
void viewExposesNewFields() {
|
||||||
|
controller.createSession(API_KEY, req("visitorView", "s1"));
|
||||||
|
// Flip pinned via the service (endpoint comes in PR 3) so we can assert the view mirrors it.
|
||||||
|
String cid = WebChatController.deriveConversationId(API_KEY, "visitorView", "s1");
|
||||||
|
conversationService.setPinned(cid, true);
|
||||||
|
|
||||||
|
R<List<WebChatSessionView>> r = controller.listSessions(
|
||||||
|
API_KEY, tokenFor("visitorView"), "visitorView", false);
|
||||||
|
assertThat(r.getCode()).isEqualTo(200);
|
||||||
|
assertThat(r.getData()).hasSize(1);
|
||||||
|
WebChatSessionView view = r.getData().get(0);
|
||||||
|
assertThat(view.getPinned()).isEqualTo(1);
|
||||||
|
assertThat(view.getArchived()).isZero();
|
||||||
|
assertThat(view.getStreamStatus()).isEqualTo("idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("archived threads are hidden from /sessions by default")
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void archivedHiddenByDefault() {
|
||||||
|
controller.createSession(API_KEY, req("visitorHide", "active"));
|
||||||
|
controller.createSession(API_KEY, req("visitorHide", "stale"));
|
||||||
|
|
||||||
|
String staleCid = WebChatController.deriveConversationId(API_KEY, "visitorHide", "stale");
|
||||||
|
jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", staleCid);
|
||||||
|
|
||||||
|
// Default: only "active" is returned.
|
||||||
|
R<?> def = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", false);
|
||||||
|
assertThat(((List<WebChatSessionView>) def.getData()))
|
||||||
|
.extracting(WebChatSessionView::getSessionId)
|
||||||
|
.containsExactly("active");
|
||||||
|
|
||||||
|
// includeArchived=true: both.
|
||||||
|
R<?> all = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", true);
|
||||||
|
assertThat(((List<WebChatSessionView>) all.getData()))
|
||||||
|
.extracting(WebChatSessionView::getSessionId)
|
||||||
|
.containsExactlyInAnyOrder("active", "stale");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("archived empty threads don't count against the 5-empty-session quota")
|
||||||
|
void archivedExcludedFromQuota() {
|
||||||
|
// Pre-seed 5 archived empty threads + verify a 6th (active) creation still succeeds —
|
||||||
|
// the quota gate filters archived out, so the active count is 0 here.
|
||||||
|
String owner = WebChatController.webchatUsername("visitorQuota");
|
||||||
|
for (int i = 1; i <= 5; i++) {
|
||||||
|
String cid = WebChatController.deriveConversationId(API_KEY, "visitorQuota", "arch" + i);
|
||||||
|
conversationService.getOrCreateWebchatConversation(
|
||||||
|
cid, AGENT_ID, owner, 1L, "arch" + i);
|
||||||
|
jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
R<?> r = controller.createSession(API_KEY, req("visitorQuota", "fresh"));
|
||||||
|
assertThat(r.getCode())
|
||||||
|
.as("archived threads must not saturate the empty-session quota")
|
||||||
|
.isEqualTo(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -76,7 +76,7 @@ class WebChatSessionManagementTest {
|
|||||||
@DisplayName("listSessions includes the hashed long-id thread with its sessionId recovered")
|
@DisplayName("listSessions includes the hashed long-id thread with its sessionId recovered")
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void listsHashedThread() {
|
void listsHashedThread() {
|
||||||
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, token, VISITOR);
|
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, token, VISITOR, false);
|
||||||
assertThat(r.getCode()).isEqualTo(200);
|
assertThat(r.getCode()).isEqualTo(200);
|
||||||
List<WebChatSessionView> sessions = r.getData();
|
List<WebChatSessionView> sessions = r.getData();
|
||||||
assertThat(sessions).hasSize(3);
|
assertThat(sessions).hasSize(3);
|
||||||
@ -88,21 +88,21 @@ class WebChatSessionManagementTest {
|
|||||||
@DisplayName("bad visitor token is rejected")
|
@DisplayName("bad visitor token is rejected")
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void rejectsBadToken() {
|
void rejectsBadToken() {
|
||||||
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, "bogus", VISITOR);
|
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, "bogus", VISITOR, false);
|
||||||
assertThat(r.getCode()).isEqualTo(401);
|
assertThat(r.getCode()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("pageSessions paginates and keyword-searches by title")
|
@DisplayName("pageSessions paginates and keyword-searches by title")
|
||||||
void pagesAndSearches() {
|
void pagesAndSearches() {
|
||||||
R<Map<String, Object>> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null);
|
R<Map<String, Object>> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null, false);
|
||||||
assertThat(page.getCode()).isEqualTo(200);
|
assertThat(page.getCode()).isEqualTo(200);
|
||||||
assertThat(page.getData().get("total")).isEqualTo(3L);
|
assertThat(page.getData().get("total")).isEqualTo(3L);
|
||||||
assertThat((List<?>) page.getData().get("items")).hasSize(2);
|
assertThat((List<?>) page.getData().get("items")).hasSize(2);
|
||||||
|
|
||||||
// Rename one thread, then search for it.
|
// Rename one thread, then search for it.
|
||||||
controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "QuarterlyReport"));
|
controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "QuarterlyReport"));
|
||||||
R<Map<String, Object>> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly");
|
R<Map<String, Object>> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly", false);
|
||||||
assertThat((List<?>) hit.getData().get("items")).hasSize(1);
|
assertThat((List<?>) hit.getData().get("items")).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user