test: share feishu schema and reaction assertions

This commit is contained in:
Peter Steinberger 2026-03-13 21:58:41 +00:00
parent a7e5925ec1
commit 7ca8804a33
1 changed files with 48 additions and 50 deletions

View File

@ -78,6 +78,25 @@ async function resolveReactionWithLookup(params: {
}); });
} }
async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) {
return await resolveReactionSyntheticEvent({
cfg: params?.cfg ?? cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group",
chatType: "group",
senderOpenId: "ou_other",
senderType: "user",
content: "hello",
contentType: "text",
}),
...(params?.uuid ? { uuid: params.uuid } : {}),
});
}
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]; type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
function buildDebounceConfig(): ClawdbotConfig { function buildDebounceConfig(): ClawdbotConfig {
@ -179,6 +198,19 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
return firstParams.event; return firstParams.event;
} }
function expectSingleDispatchedEvent(): FeishuMessageEvent {
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
return getFirstDispatchedEvent();
}
function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") {
const dispatched = expectSingleDispatchedEvent();
return {
dispatched,
parsed: parseFeishuMessageEvent(dispatched, botOpenId),
};
}
function setDedupPassThroughMocks(): void { function setDedupPassThroughMocks(): void {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
@ -203,6 +235,13 @@ async function enqueueDebouncedMessage(
await Promise.resolve(); await Promise.resolve();
} }
function setStaleRetryMocks(messageId = "om_old") {
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(`:${messageId}`));
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
async (currentMessageId) => currentMessageId === messageId,
);
}
describe("resolveReactionSyntheticEvent", () => { describe("resolveReactionSyntheticEvent", () => {
it("filters app self-reactions", async () => { it("filters app self-reactions", async () => {
const event = makeReactionEvent({ operator_type: "app" }); const event = makeReactionEvent({ operator_type: "app" });
@ -262,28 +301,12 @@ describe("resolveReactionSyntheticEvent", () => {
}); });
it("filters reactions on non-bot messages", async () => { it("filters reactions on non-bot messages", async () => {
const event = makeReactionEvent(); const result = await resolveNonBotReaction();
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event,
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group",
chatType: "group",
senderOpenId: "ou_other",
senderType: "user",
content: "hello",
contentType: "text",
}),
});
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("allows non-bot reactions when reactionNotifications is all", async () => { it("allows non-bot reactions when reactionNotifications is all", async () => {
const event = makeReactionEvent(); const result = await resolveNonBotReaction({
const result = await resolveReactionSyntheticEvent({
cfg: { cfg: {
channels: { channels: {
feishu: { feishu: {
@ -291,18 +314,6 @@ describe("resolveReactionSyntheticEvent", () => {
}, },
}, },
} as ClawdbotConfig, } as ClawdbotConfig,
accountId: "default",
event,
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group",
chatType: "group",
senderOpenId: "ou_other",
senderType: "user",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid", uuid: () => "fixed-uuid",
}); });
expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid"); expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
@ -457,8 +468,7 @@ describe("Feishu inbound debounce regressions", () => {
); );
await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); const dispatched = expectSingleDispatchedEvent();
const dispatched = getFirstDispatchedEvent();
const mergedMentions = dispatched.message.mentions ?? []; const mergedMentions = dispatched.message.mentions ?? [];
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true); expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false); expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
@ -517,9 +527,7 @@ describe("Feishu inbound debounce regressions", () => {
); );
await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
const dispatched = getFirstDispatchedEvent();
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
expect(parsed.mentionedBot).toBe(true); expect(parsed.mentionedBot).toBe(true);
expect(parsed.mentionTargets).toBeUndefined(); expect(parsed.mentionTargets).toBeUndefined();
const mergedMentions = dispatched.message.mentions ?? []; const mergedMentions = dispatched.message.mentions ?? [];
@ -547,19 +555,14 @@ describe("Feishu inbound debounce regressions", () => {
); );
await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); const { parsed } = expectParsedFirstDispatchedEvent();
const dispatched = getFirstDispatchedEvent();
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
expect(parsed.mentionedBot).toBe(true); expect(parsed.mentionedBot).toBe(true);
}); });
it("excludes previously processed retries from combined debounce text", async () => { it("excludes previously processed retries from combined debounce text", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); setStaleRetryMocks();
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
async (messageId) => messageId === "om_old",
);
const onMessage = await setupDebounceMonitor(); const onMessage = await setupDebounceMonitor();
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" })); await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
@ -576,8 +579,7 @@ describe("Feishu inbound debounce regressions", () => {
await Promise.resolve(); await Promise.resolve();
await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); const dispatched = expectSingleDispatchedEvent();
const dispatched = getFirstDispatchedEvent();
expect(dispatched.message.message_id).toBe("om_new_2"); expect(dispatched.message.message_id).toBe("om_new_2");
const combined = JSON.parse(dispatched.message.content) as { text?: string }; const combined = JSON.parse(dispatched.message.content) as { text?: string };
expect(combined.text).toBe("first\nsecond"); expect(combined.text).toBe("first\nsecond");
@ -586,10 +588,7 @@ describe("Feishu inbound debounce regressions", () => {
it("uses latest fresh message id when debounce batch ends with stale retry", async () => { it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); setStaleRetryMocks();
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
async (messageId) => messageId === "om_old",
);
const onMessage = await setupDebounceMonitor(); const onMessage = await setupDebounceMonitor();
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" })); await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
@ -600,8 +599,7 @@ describe("Feishu inbound debounce regressions", () => {
await Promise.resolve(); await Promise.resolve();
await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); const dispatched = expectSingleDispatchedEvent();
const dispatched = getFirstDispatchedEvent();
expect(dispatched.message.message_id).toBe("om_new"); expect(dispatched.message.message_id).toBe("om_new");
const combined = JSON.parse(dispatched.message.content) as { text?: string }; const combined = JSON.parse(dispatched.message.content) as { text?: string };
expect(combined.text).toBe("fresh"); expect(combined.text).toBe("fresh");