mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
825 lines
29 KiB
TypeScript
825 lines
29 KiB
TypeScript
// 验证 OneTalk 安全读取领域投影
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type {
|
|
OneTalkMessage,
|
|
OneTalkRenderedCardContent,
|
|
} from "@trade-message-center/onetalk-contract";
|
|
|
|
import {
|
|
createOneTalkReadService,
|
|
decodeOneTalkHistoryReadCursor,
|
|
decodeOneTalkListCursor,
|
|
encodeOneTalkHistoryReadCursor,
|
|
encodeOneTalkListCursor,
|
|
normalizeOneTalkReadQuery,
|
|
type CenterMessage,
|
|
type OneTalkReadConversationQuery,
|
|
type OneTalkReadConversationRow,
|
|
type OneTalkReadHistoryQuery,
|
|
type OneTalkReadListQuery,
|
|
type OneTalkReadMessageRow,
|
|
type OneTalkReadRepository,
|
|
} from "../src/onetalk/index.ts";
|
|
import { projectCenterMessage, toOneTalkCenterMessage } from "../src/onetalk/read-projection.ts";
|
|
|
|
const scope = {
|
|
mindUserId: "mind-user-1",
|
|
workspaceId: "workspace-1",
|
|
channelAccountId: "account-1",
|
|
};
|
|
|
|
const conversation = (
|
|
overrides: Partial<OneTalkReadConversationRow> = {},
|
|
): OneTalkReadConversationRow => ({
|
|
channelAccountId: scope.channelAccountId,
|
|
conversationId: "conversation-1",
|
|
name: null,
|
|
avatarUrl: null,
|
|
countryCode: null,
|
|
companyName: null,
|
|
lastContactTimeMs: null,
|
|
messagePreview: null,
|
|
buyerTags: null,
|
|
buyerFeatures: null,
|
|
latestMessageId: null,
|
|
latestMessageAtMs: null,
|
|
historyComplete: false,
|
|
messageCount: 0,
|
|
syncPhase: "initial",
|
|
syncResult: "incomplete",
|
|
...overrides,
|
|
});
|
|
|
|
const message = (overrides: Partial<OneTalkReadMessageRow> = {}): OneTalkReadMessageRow => ({
|
|
channelAccountId: scope.channelAccountId,
|
|
conversationId: "conversation-1",
|
|
messageId: "message-1",
|
|
senderId: "sender-1",
|
|
direction: "received",
|
|
sentAtMs: 1_700_000_000_000,
|
|
content: { version: 1, kind: "text", text: "hello" },
|
|
participantIds: ["sender-1", "account-1"],
|
|
readStatus: 1,
|
|
...overrides,
|
|
});
|
|
|
|
const messageFieldsForProjectionTest = () => ({
|
|
messageId: "message-1",
|
|
conversationId: "conversation-1",
|
|
senderId: "sender-1",
|
|
participantIds: ["sender-1", "account-1"],
|
|
direction: "received" as const,
|
|
sentAtMs: 1_700_000_000_000,
|
|
readStatus: "read" as const,
|
|
});
|
|
|
|
const createRepositoryHarness = (
|
|
options: {
|
|
listRows?: () => OneTalkReadConversationRow[];
|
|
conversation?: () => OneTalkReadConversationRow | null;
|
|
messageRows?: () => OneTalkReadMessageRow[];
|
|
} = {},
|
|
) => {
|
|
const listQueries: OneTalkReadListQuery[] = [];
|
|
const conversationQueries: OneTalkReadConversationQuery[] = [];
|
|
const historyQueries: OneTalkReadHistoryQuery[] = [];
|
|
const repository: OneTalkReadRepository = {
|
|
listConversations: async (query) => {
|
|
listQueries.push(query);
|
|
return options.listRows?.() ?? [];
|
|
},
|
|
findConversation: async (query) => {
|
|
conversationQueries.push(query);
|
|
return options.conversation?.() ?? conversation();
|
|
},
|
|
listMessages: async (query) => {
|
|
historyQueries.push(query);
|
|
return options.messageRows?.() ?? [];
|
|
},
|
|
};
|
|
return { repository, listQueries, conversationQueries, historyQueries };
|
|
};
|
|
|
|
test("normalizes names as trimmed Unicode-insensitive query values", () => {
|
|
assert.equal(normalizeOneTalkReadQuery(" ÅNGSTRÖM "), "ångström");
|
|
assert.equal(normalizeOneTalkReadQuery(" "), null);
|
|
assert.equal(normalizeOneTalkReadQuery(null), null);
|
|
});
|
|
|
|
test("projects one direct conversation with the exact public field whitelist", async () => {
|
|
const harness = createRepositoryHarness({
|
|
conversation: () =>
|
|
conversation({
|
|
name: "Current profile",
|
|
avatarUrl: "https://cdn.example.com/avatar.jpg",
|
|
buyerTags: ["high-potential"],
|
|
buyerFeatures: ["repeat-buyer"],
|
|
email: "buyer@example.test",
|
|
registrationDate: "2025-03-12",
|
|
companyWebsite: "https://example.test/",
|
|
countryCode: "US",
|
|
companyName: "Current Profile LLC",
|
|
lastContactTimeMs: 1_700_000_000_100,
|
|
messagePreview: "latest preview",
|
|
latestMessageId: "persisted-latest-message",
|
|
latestMessageAtMs: 1_700_000_000_100,
|
|
historyComplete: true,
|
|
messageCount: 3,
|
|
syncPhase: "incremental",
|
|
syncResult: "succeeded",
|
|
}),
|
|
});
|
|
const service = createOneTalkReadService(harness.repository, {
|
|
now: () => new Date("2026-09-03T00:00:00.000Z"),
|
|
});
|
|
|
|
const result = await service.readConversation({ scope, conversationId: "conversation-1" });
|
|
assert.equal(result.status, "accepted");
|
|
if (result.status !== "accepted") return;
|
|
assert.deepEqual(result.conversation, {
|
|
conversationId: "conversation-1",
|
|
conversationType: "direct",
|
|
name: "Current profile",
|
|
avatarUrl: "https://cdn.example.com/avatar.jpg",
|
|
customerProfile: {
|
|
name: "Current profile",
|
|
avatarUrl: "https://cdn.example.com/avatar.jpg",
|
|
buyerTags: ["high-potential"],
|
|
buyerFeatures: ["repeat-buyer"],
|
|
email: "buyer@example.test",
|
|
registrationDate: "2025-03-12",
|
|
companyWebsite: "https://example.test/",
|
|
countryCode: "US",
|
|
companyName: "Current Profile LLC",
|
|
},
|
|
lastContactTimeLong: 1_700_000_000_100,
|
|
messagePreview: "latest preview",
|
|
});
|
|
assert.deepEqual(Object.keys(result.conversation).sort(), [
|
|
"avatarUrl",
|
|
"conversationId",
|
|
"conversationType",
|
|
"customerProfile",
|
|
"lastContactTimeLong",
|
|
"messagePreview",
|
|
"name",
|
|
]);
|
|
});
|
|
|
|
test("uses a canonical list cursor and permits current profile changes on later pages", async () => {
|
|
let laterName = "Before update";
|
|
const first = conversation({
|
|
conversationId: "conversation-a",
|
|
name: "First profile",
|
|
latestMessageId: "message-a",
|
|
latestMessageAtMs: 200,
|
|
lastContactTimeMs: 200,
|
|
});
|
|
const later = () =>
|
|
conversation({
|
|
conversationId: "conversation-b",
|
|
name: laterName,
|
|
latestMessageId: "message-b",
|
|
latestMessageAtMs: 100,
|
|
lastContactTimeMs: 100,
|
|
});
|
|
const harness = createRepositoryHarness({
|
|
listRows: () => (harness.listQueries.at(-1)?.cursor ? [later()] : [first, later()]),
|
|
});
|
|
const service = createOneTalkReadService(harness.repository, {
|
|
now: () => new Date("2026-09-03T00:00:00.000Z"),
|
|
});
|
|
|
|
const firstPage = await service.listConversations({ scope, query: " FIRST ", limit: 1 });
|
|
assert.equal(firstPage.status, "accepted");
|
|
if (firstPage.status !== "accepted") return;
|
|
assert.equal(firstPage.conversations[0]?.name, "First profile");
|
|
assert.equal(firstPage.page.hasMore, true);
|
|
assert.ok(firstPage.page.nextCursor);
|
|
const cursor = decodeOneTalkListCursor(firstPage.page.nextCursor);
|
|
assert.deepEqual(cursor, {
|
|
channelAccountId: "account-1",
|
|
conversationId: "conversation-a",
|
|
lastContactTimeMs: 200,
|
|
query: "first",
|
|
});
|
|
|
|
laterName = "Current profile";
|
|
const secondPage = await service.listConversations({
|
|
scope,
|
|
query: "first",
|
|
cursor: firstPage.page.nextCursor,
|
|
limit: 1,
|
|
});
|
|
assert.equal(secondPage.status, "accepted");
|
|
if (secondPage.status !== "accepted") return;
|
|
assert.equal(secondPage.conversations[0]?.name, "Current profile");
|
|
assert.equal(harness.listQueries[1]?.cursor?.lastContactTimeMs, cursor?.lastContactTimeMs);
|
|
|
|
const mismatchedQuery = await service.listConversations({
|
|
scope,
|
|
query: "different",
|
|
cursor: firstPage.page.nextCursor,
|
|
});
|
|
assert.deepEqual(mismatchedQuery, { status: "rejected", reason: "invalid_cursor" });
|
|
assert.equal(harness.listQueries.length, 2);
|
|
});
|
|
|
|
test("rejects malformed list cursors and unsupported limits before querying", async () => {
|
|
const harness = createRepositoryHarness();
|
|
const service = createOneTalkReadService(harness.repository);
|
|
const canonical = encodeOneTalkListCursor({
|
|
channelAccountId: scope.channelAccountId,
|
|
conversationId: "conversation-1",
|
|
query: null,
|
|
lastContactTimeMs: null,
|
|
});
|
|
|
|
assert.equal(decodeOneTalkListCursor(`${canonical}=`), null);
|
|
assert.deepEqual(decodeOneTalkListCursor(canonical), {
|
|
channelAccountId: scope.channelAccountId,
|
|
conversationId: "conversation-1",
|
|
query: null,
|
|
lastContactTimeMs: null,
|
|
});
|
|
assert.deepEqual(await service.listConversations({ scope, cursor: canonical, limit: 0 }), {
|
|
status: "rejected",
|
|
reason: "invalid_limit",
|
|
});
|
|
assert.deepEqual(
|
|
await service.listConversations({
|
|
scope: { ...scope, channelAccountId: "other" },
|
|
cursor: canonical,
|
|
}),
|
|
{ status: "rejected", reason: "invalid_cursor" },
|
|
);
|
|
assert.equal(harness.listQueries.length, 0);
|
|
});
|
|
|
|
test("round-trips a valid list cursor with a long query", () => {
|
|
const cursor = {
|
|
channelAccountId: scope.channelAccountId,
|
|
conversationId: "conversation-1",
|
|
query: "q".repeat(2_000),
|
|
lastContactTimeMs: null,
|
|
};
|
|
|
|
const encoded = encodeOneTalkListCursor(cursor);
|
|
|
|
assert.deepEqual(decodeOneTalkListCursor(encoded), cursor);
|
|
});
|
|
|
|
test("uses one snapshot and half-open window for history page pairs", async () => {
|
|
const rows = [
|
|
message({ messageId: "message-a", sentAtMs: 10 }),
|
|
message({ messageId: "message-b", sentAtMs: 20 }),
|
|
];
|
|
const harness = createRepositoryHarness({
|
|
conversation: () => conversation({ historyComplete: true }),
|
|
messageRows: () => rows,
|
|
});
|
|
const service = createOneTalkReadService(harness.repository, {
|
|
now: () => new Date("2026-09-03T00:00:00.000Z"),
|
|
});
|
|
|
|
const firstPage = await service.readHistory({
|
|
scope,
|
|
conversationId: "conversation-1",
|
|
fromSentAtMs: 10,
|
|
toSentAtMs: 30,
|
|
limit: 1,
|
|
});
|
|
assert.equal(firstPage.status, "accepted");
|
|
if (firstPage.status !== "accepted") return;
|
|
assert.deepEqual(
|
|
firstPage.messages.map(({ messageId }) => messageId),
|
|
["message-a"],
|
|
);
|
|
assert.equal(firstPage.page.hasMore, true);
|
|
assert.ok(firstPage.page.nextCursor);
|
|
assert.deepEqual(harness.historyQueries[0]?.window, { fromSentAtMs: 10, toSentAtMs: 30 });
|
|
const cursor = decodeOneTalkHistoryReadCursor(firstPage.page.nextCursor);
|
|
assert.equal(cursor?.asOfMs, undefined);
|
|
|
|
const mismatch = await service.readHistory({
|
|
scope,
|
|
conversationId: "conversation-1",
|
|
fromSentAtMs: 10,
|
|
toSentAtMs: 31,
|
|
cursor: firstPage.page.nextCursor,
|
|
});
|
|
assert.deepEqual(mismatch, { status: "rejected", reason: "invalid_cursor" });
|
|
assert.deepEqual(
|
|
await service.readHistory({
|
|
scope,
|
|
conversationId: "conversation-1",
|
|
fromSentAtMs: 30,
|
|
toSentAtMs: 30,
|
|
}),
|
|
{ status: "rejected", reason: "invalid_time_range" },
|
|
);
|
|
assert.equal(harness.historyQueries.length, 1);
|
|
});
|
|
|
|
test("paginates more than one hundred history messages toward older records", async () => {
|
|
const rows = Array.from({ length: 101 }, (_, index) =>
|
|
message({
|
|
messageId: `message-${101 - index}`,
|
|
sentAtMs: 1_000 - index,
|
|
}),
|
|
);
|
|
const harness = createRepositoryHarness({
|
|
conversation: () => conversation({ historyComplete: true }),
|
|
messageRows: () => {
|
|
const query = harness.historyQueries.at(-1);
|
|
if (!query?.cursor) return rows;
|
|
return rows.filter(
|
|
(row) =>
|
|
row.sentAtMs < query.cursor!.sentAtMs ||
|
|
(row.sentAtMs === query.cursor!.sentAtMs &&
|
|
row.messageId < query.cursor!.messageId),
|
|
);
|
|
},
|
|
});
|
|
const service = createOneTalkReadService(harness.repository);
|
|
|
|
const firstPage = await service.readHistory({
|
|
scope,
|
|
conversationId: "conversation-1",
|
|
fromSentAtMs: 0,
|
|
toSentAtMs: 2_000,
|
|
limit: 100,
|
|
purpose: "communication_summary_read",
|
|
});
|
|
assert.equal(firstPage.status, "accepted");
|
|
if (firstPage.status !== "accepted") return;
|
|
assert.equal(firstPage.messages.length, 100);
|
|
assert.equal(firstPage.messages[0]?.sentAtMs, 901);
|
|
assert.equal(firstPage.messages.at(-1)?.sentAtMs, 1_000);
|
|
assert.equal(firstPage.page.hasMore, true);
|
|
|
|
const secondPage = await service.readHistory({
|
|
scope,
|
|
conversationId: "conversation-1",
|
|
fromSentAtMs: 0,
|
|
toSentAtMs: 2_000,
|
|
limit: 100,
|
|
cursor: firstPage.page.nextCursor,
|
|
purpose: "communication_summary_read",
|
|
});
|
|
assert.equal(secondPage.status, "accepted");
|
|
if (secondPage.status !== "accepted") return;
|
|
assert.deepEqual(
|
|
secondPage.messages.map(({ sentAtMs }) => sentAtMs),
|
|
[900],
|
|
);
|
|
assert.deepEqual(secondPage.page, { hasMore: false, nextCursor: null });
|
|
});
|
|
|
|
test("keeps partial ordinary history readable but gates summary reads", async () => {
|
|
const harness = createRepositoryHarness({ messageRows: () => [message()] });
|
|
const service = createOneTalkReadService(harness.repository);
|
|
|
|
const ordinary = await service.readHistory({ scope, conversationId: "conversation-1" });
|
|
assert.equal(ordinary.status, "accepted");
|
|
assert.equal(harness.historyQueries.length, 1);
|
|
const summary = await service.readHistory({
|
|
scope,
|
|
conversationId: "conversation-1",
|
|
purpose: "communication_summary_read",
|
|
});
|
|
assert.deepEqual(summary, { status: "rejected", reason: "history_incomplete" });
|
|
assert.equal(harness.historyQueries.length, 1);
|
|
});
|
|
|
|
test("projects normalized text, image, and file content without raw reinterpretation", async () => {
|
|
const image = {
|
|
version: 1,
|
|
kind: "image",
|
|
fileId: "image-file-1",
|
|
extension: "jpg",
|
|
sizeBytes: 42_000,
|
|
isOriginal: true,
|
|
md5: "a".repeat(32),
|
|
previewUrl:
|
|
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=oneTalk&fileAction=imagePreview&id=image-file-1&scene=oneTalk",
|
|
urlScope: "onetalk_session",
|
|
} as const;
|
|
const file = {
|
|
version: 1,
|
|
kind: "file",
|
|
fileId: "file-1",
|
|
parentId: "parent-1",
|
|
fileName: "quote.pdf",
|
|
extension: "pdf",
|
|
sizeBytes: 24_000,
|
|
md5: "b".repeat(32),
|
|
previewUrl: null,
|
|
thumbnailUrl: null,
|
|
downloadUrl:
|
|
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=oneTalk&fileAction=download&id=file-1&parentId=parent-1&scene=oneTalk",
|
|
downloadState: "available",
|
|
urlScope: "onetalk_session",
|
|
} as const;
|
|
const harness = createRepositoryHarness({
|
|
conversation: () => conversation({ historyComplete: true }),
|
|
messageRows: () => [
|
|
message({ messageId: "file", sentAtMs: 30, content: file, readStatus: 0 }),
|
|
message({ messageId: "image", sentAtMs: 20, content: image }),
|
|
message({
|
|
messageId: "text",
|
|
sentAtMs: 10,
|
|
content: { version: 1, kind: "text", text: "hello" },
|
|
}),
|
|
],
|
|
});
|
|
const service = createOneTalkReadService(harness.repository);
|
|
|
|
const result = await service.readHistory({ scope, conversationId: "conversation-1", limit: 3 });
|
|
|
|
assert.equal(result.status, "accepted");
|
|
if (result.status !== "accepted") return;
|
|
assert.deepEqual(
|
|
result.messages.map(({ content }) => content),
|
|
[{ version: 1, kind: "text", text: "hello" }, image, file],
|
|
);
|
|
for (const projected of result.messages) {
|
|
assert.deepEqual(Object.keys(projected).sort(), [
|
|
"content",
|
|
"conversationId",
|
|
"direction",
|
|
"messageId",
|
|
"participantIds",
|
|
"senderId",
|
|
"sentAtMs",
|
|
]);
|
|
assert.equal("contentType" in projected, false);
|
|
assert.equal("messageStatus" in projected, false);
|
|
assert.equal("unreadCount" in projected, false);
|
|
}
|
|
});
|
|
|
|
test("projects business cards from the linked customer profile and leaves missing profiles as markers", async () => {
|
|
const businessCard = { version: 1 as const, kind: "business_card" as const };
|
|
const profileHarness = createRepositoryHarness({
|
|
conversation: () =>
|
|
conversation({
|
|
name: "Customer One",
|
|
avatarUrl: "https://cdn.example.test/customer.jpg",
|
|
countryCode: "US",
|
|
companyName: "Customer One LLC",
|
|
}),
|
|
messageRows: () => [message({ content: businessCard })],
|
|
});
|
|
const profileResult = await createOneTalkReadService(profileHarness.repository).readHistory({
|
|
scope,
|
|
conversationId: "conversation-1",
|
|
});
|
|
|
|
assert.equal(profileResult.status, "accepted");
|
|
if (profileResult.status !== "accepted") return;
|
|
assert.deepEqual(profileResult.messages[0]?.content, {
|
|
...businessCard,
|
|
contactName: "Customer One",
|
|
companyName: "Customer One LLC",
|
|
countryCode: "US",
|
|
avatarUrl: "https://cdn.example.test/customer.jpg",
|
|
});
|
|
assert.equal(profileHarness.historyQueries.length, 1);
|
|
|
|
const missingProfileHarness = createRepositoryHarness({
|
|
messageRows: () => [message({ content: businessCard })],
|
|
});
|
|
const missingProfileResult = await createOneTalkReadService(
|
|
missingProfileHarness.repository,
|
|
).readHistory({ scope, conversationId: "conversation-1" });
|
|
|
|
assert.equal(missingProfileResult.status, "accepted");
|
|
if (missingProfileResult.status !== "accepted") return;
|
|
assert.deepEqual(missingProfileResult.messages[0]?.content, businessCard);
|
|
assert.deepEqual(businessCard, { version: 1, kind: "business_card" });
|
|
});
|
|
|
|
test("projects one persisted media fact identically for history and message.created", () => {
|
|
const fact: OneTalkMessage = {
|
|
messageId: "image-1",
|
|
conversationId: "conversation-1",
|
|
senderId: "sender-1",
|
|
participantIds: ["sender-1", "account-1"],
|
|
direction: "received",
|
|
sentAtMs: 1_700_000_000_000,
|
|
readStatus: 1,
|
|
messageStatus: 2,
|
|
unreadCount: 0,
|
|
content: {
|
|
version: 1,
|
|
kind: "image",
|
|
fileId: "image-file-1",
|
|
extension: "jpg",
|
|
sizeBytes: 42_000,
|
|
isOriginal: true,
|
|
md5: null,
|
|
previewUrl: null,
|
|
urlScope: "onetalk_session",
|
|
},
|
|
};
|
|
|
|
const historyMessage = projectCenterMessage({
|
|
channelAccountId: scope.channelAccountId,
|
|
...fact,
|
|
});
|
|
const liveMessage = toOneTalkCenterMessage(fact);
|
|
|
|
assert.deepEqual(historyMessage, liveMessage);
|
|
assert.equal("contentType" in historyMessage, false);
|
|
assert.equal("text" in historyMessage, false);
|
|
});
|
|
|
|
test("projects one persisted product fact identically and rejects raw-query JSONB at the read boundary", () => {
|
|
const product = {
|
|
version: 1 as const,
|
|
kind: "product" as const,
|
|
sourceUrl:
|
|
"https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html",
|
|
productId: "1601456609478",
|
|
};
|
|
const fact: OneTalkMessage = {
|
|
messageId: "product-1",
|
|
conversationId: "conversation-1",
|
|
senderId: "sender-1",
|
|
participantIds: ["sender-1", "account-1"],
|
|
direction: "received",
|
|
sentAtMs: 1_700_000_000_000,
|
|
readStatus: 1,
|
|
messageStatus: 2,
|
|
unreadCount: 0,
|
|
content: product,
|
|
};
|
|
|
|
const historyMessage = projectCenterMessage({
|
|
channelAccountId: scope.channelAccountId,
|
|
...fact,
|
|
});
|
|
const liveMessage = toOneTalkCenterMessage(fact);
|
|
assert.deepEqual(historyMessage, liveMessage);
|
|
assert.deepEqual(historyMessage.content, product);
|
|
assert.equal(JSON.stringify(historyMessage).includes("chatToken"), false);
|
|
|
|
const malformed = {
|
|
...fact,
|
|
content: { ...product, sourceUrl: `${product.sourceUrl}?chatToken=secret` },
|
|
} as unknown as OneTalkMessage;
|
|
assert.throws(
|
|
() => toOneTalkCenterMessage(malformed),
|
|
/Invalid persisted OneTalk message content/,
|
|
);
|
|
});
|
|
|
|
test("projects inquiry, product, and order supplements without changing their base kinds", () => {
|
|
const product = {
|
|
version: 1 as const,
|
|
kind: "product" as const,
|
|
sourceUrl:
|
|
"https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html",
|
|
productId: "1601456609478",
|
|
};
|
|
const cases: Array<{
|
|
content: OneTalkMessage["content"];
|
|
renderedCardContent: OneTalkRenderedCardContent;
|
|
}> = [
|
|
{
|
|
content: { version: 1 as const, kind: "inquiry" as const },
|
|
renderedCardContent: {
|
|
version: 1 as const,
|
|
kind: "rendered_inquiry" as const,
|
|
product: { imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" },
|
|
purchaseQuantity: { value: "100", unit: "pieces" },
|
|
requirementText: "Need delivery this week",
|
|
inquiryReference: "inquiry-1",
|
|
actions: [{ label: "Reply", available: true }],
|
|
},
|
|
},
|
|
{
|
|
content: product,
|
|
renderedCardContent: {
|
|
version: 1 as const,
|
|
kind: "rendered_product" as const,
|
|
product: {
|
|
imageUrl: "https://img.alicdn.com/item.jpg",
|
|
title: "Widget",
|
|
sourceUrl: product.sourceUrl,
|
|
productId: product.productId,
|
|
},
|
|
priceDisplay: "US $10.00",
|
|
minimumOrder: { value: "1", unit: "piece" },
|
|
serviceBadges: ["Fast dispatch"],
|
|
},
|
|
},
|
|
{
|
|
content: {
|
|
version: 1 as const,
|
|
kind: "order" as const,
|
|
orderId: "order-1",
|
|
bizCode: null,
|
|
contractId: null,
|
|
id: null,
|
|
tenant: null,
|
|
orderAmount: 10,
|
|
orderAmountCurrency: "USD",
|
|
paymentAmount: 10,
|
|
paymentAmountCurrency: "USD",
|
|
statusMessageKey: "paid",
|
|
actions: [],
|
|
},
|
|
renderedCardContent: {
|
|
version: 1 as const,
|
|
kind: "rendered_order" as const,
|
|
title: "Order summary",
|
|
products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }],
|
|
productCount: 1,
|
|
status: { code: "paid", text: "Paid" },
|
|
payment: { totalDisplay: "US $10.00", discountDisplay: null },
|
|
delivery: {
|
|
shippingAddress: "1 Market Street",
|
|
methodLabel: null,
|
|
dateLabel: null,
|
|
},
|
|
action: { label: "View order", status: "available" },
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const entry of cases) {
|
|
const persisted = message({
|
|
content: entry.content,
|
|
renderedCardContent: entry.renderedCardContent,
|
|
});
|
|
const history = projectCenterMessage(persisted);
|
|
const live = toOneTalkCenterMessage(persisted, entry.renderedCardContent);
|
|
|
|
assert.deepEqual(history, live);
|
|
assert.equal(history.content.kind, entry.content.kind);
|
|
assert.equal(history.content.kind.startsWith("rendered_"), false);
|
|
}
|
|
});
|
|
|
|
test("attaches a generic DOM card beside an unchanged base message", () => {
|
|
const renderedCard = {
|
|
version: 1 as const,
|
|
status: "Paid",
|
|
total: "US $10.00",
|
|
image: "https://img.alicdn.com/card.jpg",
|
|
};
|
|
const result = toOneTalkCenterMessage(message(), renderedCard);
|
|
|
|
assert.deepEqual(result.content, message().content);
|
|
assert.deepEqual(result.renderedCard, renderedCard);
|
|
});
|
|
|
|
test("rejects a rendered-card supplement with a different base kind or product identity", () => {
|
|
const product = {
|
|
version: 1 as const,
|
|
kind: "product" as const,
|
|
sourceUrl:
|
|
"https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609479.html",
|
|
productId: "1601456609479",
|
|
};
|
|
const renderedProduct = {
|
|
version: 1 as const,
|
|
kind: "rendered_product" as const,
|
|
product: {
|
|
imageUrl: "https://img.alicdn.com/item.jpg",
|
|
title: "Widget",
|
|
sourceUrl:
|
|
"https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html",
|
|
productId: "1601456609478",
|
|
},
|
|
priceDisplay: "US $10.00",
|
|
minimumOrder: { value: "1", unit: "piece" },
|
|
serviceBadges: ["Fast dispatch"],
|
|
};
|
|
|
|
assert.throws(
|
|
() => toOneTalkCenterMessage(message({ content: product }), renderedProduct),
|
|
/identity does not match/,
|
|
);
|
|
assert.throws(() => toOneTalkCenterMessage(message(), renderedProduct), /kind does not match/);
|
|
});
|
|
|
|
test("rejects malformed rendered-card JSONB at the read boundary", () => {
|
|
assert.throws(
|
|
() =>
|
|
projectCenterMessage({
|
|
...message(),
|
|
renderedCardContent: {
|
|
version: 1,
|
|
kind: "rendered_order",
|
|
} as never,
|
|
}),
|
|
/Invalid persisted OneTalk rendered-card supplement/,
|
|
);
|
|
});
|
|
|
|
test("CenterMessage accepts normalized media and structured-card values", () => {
|
|
const verifiedImage = {
|
|
...messageFieldsForProjectionTest(),
|
|
content: {
|
|
version: 1,
|
|
kind: "image",
|
|
fileId: "image-file-1",
|
|
extension: "jpg",
|
|
sizeBytes: 42_000,
|
|
isOriginal: true,
|
|
md5: null,
|
|
previewUrl: null,
|
|
urlScope: "onetalk_session",
|
|
},
|
|
} satisfies CenterMessage;
|
|
const verifiedFile = {
|
|
...messageFieldsForProjectionTest(),
|
|
content: {
|
|
version: 1,
|
|
kind: "file",
|
|
fileId: "file-1",
|
|
parentId: "parent-1",
|
|
fileName: "quote.pdf",
|
|
extension: "pdf",
|
|
sizeBytes: 24_000,
|
|
md5: null,
|
|
previewUrl: null,
|
|
thumbnailUrl: null,
|
|
downloadUrl: null,
|
|
downloadState: "not_provided",
|
|
urlScope: "onetalk_session",
|
|
},
|
|
} satisfies CenterMessage;
|
|
const verifiedOrder = {
|
|
...messageFieldsForProjectionTest(),
|
|
content: {
|
|
version: 1,
|
|
kind: "order",
|
|
orderId: "order-1",
|
|
bizCode: 42,
|
|
contractId: "contract-1",
|
|
id: "id-1",
|
|
tenant: "tenant-1",
|
|
orderAmount: 12.5,
|
|
orderAmountCurrency: "USD",
|
|
paymentAmount: 10,
|
|
paymentAmountCurrency: "USD",
|
|
statusMessageKey: "order.pending_payment",
|
|
actions: [{ name: "pay", messageKey: "order.pay", payStep: "deposit" }],
|
|
},
|
|
} satisfies CenterMessage;
|
|
const verifiedBusinessCard = {
|
|
...messageFieldsForProjectionTest(),
|
|
content: { version: 1, kind: "business_card" },
|
|
} satisfies CenterMessage;
|
|
|
|
assert.equal(verifiedImage.content.kind, "image");
|
|
assert.equal(verifiedFile.content.fileName, "quote.pdf");
|
|
assert.equal(verifiedOrder.content.statusMessageKey, "order.pending_payment");
|
|
assert.equal(verifiedBusinessCard.content.kind, "business_card");
|
|
});
|
|
|
|
test("rejects persisted image content with retired dimensions at the read boundary", () => {
|
|
const legacyImage = {
|
|
...messageFieldsForProjectionTest(),
|
|
content: {
|
|
version: 1,
|
|
kind: "image",
|
|
fileId: "image-file-1",
|
|
extension: "jpg",
|
|
sizeBytes: 42_000,
|
|
width: 1280,
|
|
height: 720,
|
|
isOriginal: true,
|
|
md5: null,
|
|
previewUrl: null,
|
|
urlScope: "onetalk_session",
|
|
},
|
|
} as unknown as OneTalkMessage;
|
|
|
|
assert.throws(
|
|
() => toOneTalkCenterMessage(legacyImage),
|
|
/Invalid persisted OneTalk message content/,
|
|
);
|
|
});
|
|
|
|
test("keeps history cursor codecs separate from legacy and list cursor shapes", () => {
|
|
const historyCursor = encodeOneTalkHistoryReadCursor({
|
|
channelAccountId: "account-1",
|
|
conversationId: "conversation-1",
|
|
fromSentAtMs: null,
|
|
toSentAtMs: 20,
|
|
asOfMs: 10,
|
|
sentAtMs: 11,
|
|
messageId: "message-1",
|
|
});
|
|
assert.equal(decodeOneTalkListCursor(historyCursor), null);
|
|
assert.equal(decodeOneTalkHistoryReadCursor("eyJ2IjoxLCJhIjoiYSJ9"), null);
|
|
});
|