Files
trade-message-center/apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js
T

167 lines
5.6 KiB
JavaScript

// 验证 type-agnostic 渲染卡片 durable-first 与精确 ACK。
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkRenderedCardContentFingerprint } from "@trade-message-center/onetalk-contract";
import { createOneTalkRenderedCardCoordinator } from "../src/onetalk/service-worker/rendered-card-coordinator.ts";
const scope = { channelAccountId: "account-1", deviceId: "device-1" };
const content = {
version: 1,
status: "Paid",
total: "US $10.00",
image: "https://img.alicdn.com/card.jpg",
};
const observation = {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 100,
};
const key = JSON.stringify([
scope.channelAccountId,
observation.conversationId,
observation.messageId,
]);
test("writes the card ledger before immediate send, resends after reconnect, and exact-matches ACK", async () => {
const timeline = [];
const records = new Map();
const acknowledgements = [];
const ledger = {
observe: async ({ channelAccountId, observation: value }) => {
timeline.push("write");
const record = {
key,
channelAccountId,
...value,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
};
records.set(key, record);
return record;
},
listPending: async () =>
[...records.values()].filter((record) => record.status === "pending_ack"),
markSent: async (input) => {
const record = records.get(key);
if (
!record ||
input.contentFingerprint !== record.contentFingerprint ||
input.observedAtMs !== record.observedAtMs
)
return null;
record.requestId = input.requestId;
return record;
},
markAcknowledged: async (input) => {
acknowledgements.push(input);
const record = records.get(key);
if (
!record ||
input.contentFingerprint !== record.contentFingerprint ||
input.observedAtMs !== record.observedAtMs ||
input.requestId !== record.requestId
)
return false;
record.status =
input.status === "accepted" || input.status === "duplicate"
? "confirmed"
: "rejected";
return true;
},
};
const frames = [];
const bright = {
isOnline: () => true,
send: (frame) => {
timeline.push("send");
frames.push(frame);
return true;
},
};
const coordinator = createOneTalkRenderedCardCoordinator({
scope,
ledger,
bright,
createRequestId: () => "card-request",
});
await coordinator.observe([observation]);
assert.equal(frames.length, 1);
assert.equal(frames[0].type, "rendered.card.observed");
assert.deepEqual(timeline, ["write", "send"]);
coordinator.handleStatus({ status: "offline", permissions: [] });
coordinator.handleStatus({ status: "authenticated", permissions: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(frames.length, 2);
coordinator.handleFrame({
...frames[0],
requestId: "stale-card-request",
type: "rendered.card.ack",
payload: { ...observation, status: "accepted" },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(records.get(key).status, "pending_ack");
coordinator.handleFrame({
...frames[1],
type: "rendered.card.ack",
payload: { ...observation, status: "accepted" },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(records.get(key).status, "confirmed");
assert.equal(acknowledgements.length, 1);
assert.equal(acknowledgements[0].requestId, frames[1].requestId);
coordinator.handleFrame({
...frames[1],
type: "rendered.card.ack",
payload: { ...observation, contentFingerprint: "wrong", status: "accepted" },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(acknowledgements.length, 1);
assert.equal(records.get(key).status, "confirmed");
});
test("recovers a pending record after worker restart without a base candidate", async () => {
const records = new Map([
[
key,
{
key,
channelAccountId: scope.channelAccountId,
...observation,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
},
],
]);
const frames = [];
const coordinator = createOneTalkRenderedCardCoordinator({
scope,
ledger: {
observe: async () =>
assert.fail("recovery must not depend on a fresh page observation"),
listPending: async () => [...records.values()],
markSent: async (input) => {
const record = records.get(key);
record.requestId = input.requestId;
return record;
},
markAcknowledged: async () => false,
},
bright: { isOnline: () => true, send: (frame) => frames.push(frame) || true },
createRequestId: () => "recovered-request",
});
await coordinator.handlePageReady();
assert.equal(frames.length, 1);
assert.equal(frames[0].type, "rendered.card.observed");
});