deer-flow/frontend/tests/unit/core/channels/notification-preferences.test.ts
Totoro c994826d06
feat(channels): generalize group notifications and add opt-in task/goal IM notices (#6903)
* feat(channels): generalize IM group targets and add task/goal run notifications

- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.

* fix(notifications): address review feedback and group/runtime notification edge cases

* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests

* fix(channels): preserve group binding names and legacy authorization

Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-15 10:30:55 +08:00

104 lines
3.4 KiB
TypeScript

import { beforeEach, expect, test, rs } from "@rstest/core";
import { GatewayApiError } from "@/core/api/errors";
import { fetch as fetcher } from "@/core/api/fetcher";
import {
loadChannelNotificationPreferences,
updateChannelNotificationPreference,
} from "@/core/channels/notification-preferences";
rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "/backend" }));
const defaults = { feishu: true, wecom: true, wechat: true, qq: true };
beforeEach(() => {
rs.mocked(fetcher).mockReset();
});
test("loads effective preferences with an expected-owner fence and consumed signal", async () => {
rs.mocked(fetcher).mockResolvedValue(new Response(JSON.stringify(defaults)));
const signal = new AbortController().signal;
expect(await loadChannelNotificationPreferences("default", signal)).toEqual(
defaults,
);
expect(rs.mocked(fetcher)).toHaveBeenCalledWith(
"/backend/api/channels/notification-preferences",
expect.objectContaining({
method: "GET",
signal,
headers: expect.objectContaining({ "X-Expected-User-Id": "default" }),
}),
);
});
test("updates exactly one provider, preserving false and the authenticated viewer header", async () => {
rs.mocked(fetcher).mockResolvedValue(
new Response(JSON.stringify({ ...defaults, qq: false })),
);
const signal = new AbortController().signal;
expect(
(await updateChannelNotificationPreference("alice", "qq", false, signal))
.qq,
).toBe(false);
expect(rs.mocked(fetcher)).toHaveBeenCalledWith(
"/backend/api/channels/notification-preferences",
expect.objectContaining({
method: "PATCH",
signal,
body: '{"qq":false}',
headers: expect.objectContaining({ "X-Expected-User-Id": "alice" }),
}),
);
});
test.each([{ feishu: true }, { ...defaults, qq: "false" }, null])(
"rejects malformed effective preferences instead of inventing defaults",
async (body) => {
rs.mocked(fetcher).mockResolvedValue(new Response(JSON.stringify(body)));
await expect(
loadChannelNotificationPreferences("alice", new AbortController().signal),
).rejects.toThrow();
},
);
test("keeps an unavailable endpoint distinguishable from valid default values", async () => {
rs.mocked(fetcher).mockResolvedValue(
new Response('{"detail":"Not found"}', { status: 404 }),
);
await expect(
loadChannelNotificationPreferences("alice", new AbortController().signal),
).rejects.toBeInstanceOf(GatewayApiError);
});
test("updates a runtime notification event key and parses optional event fields", async () => {
rs.mocked(fetcher).mockResolvedValue(
new Response(
JSON.stringify({
...defaults,
run_completed: true,
run_failed: false,
human_input_requested: true,
}),
),
);
const signal = new AbortController().signal;
const updated = await updateChannelNotificationPreference(
"alice",
"human_input_requested",
true,
signal,
);
expect(updated.human_input_requested).toBe(true);
expect(updated.run_completed).toBe(true);
expect(updated.run_failed).toBe(false);
expect(rs.mocked(fetcher)).toHaveBeenCalledWith(
"/backend/api/channels/notification-preferences",
expect.objectContaining({
method: "PATCH",
signal,
body: '{"human_input_requested":true}',
headers: expect.objectContaining({ "X-Expected-User-Id": "alice" }),
}),
);
});