mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
167 lines
5.6 KiB
JavaScript
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");
|
|
});
|