diff --git a/.env.self-hosted.example b/.env.self-hosted.example index b731e99..7e0d8c3 100644 --- a/.env.self-hosted.example +++ b/.env.self-hosted.example @@ -24,6 +24,15 @@ FEISHU_APP_SECRET= AI_TOOL_CENTER_MCP_URL= AI_TOOL_CENTER_MCP_KEY= +# 企业微信通知(临期催办 + 管理员汇总)。群机器人只需 webhook;KOC 侧催办还需 corp/agent/secret, +# 并在后台 partners 编辑里把 wecom_external_user_id 填好。 +WECOM_ROBOT_WEBHOOK= +WECOM_CORP_ID= +WECOM_AGENT_ID= +WECOM_SECRET= +WECOM_NOTIFY_DUE_DAYS=3 +WECOM_NOTIFY_ENABLED=true + # 每天北京时间 09:00 自动执行采集计划。 ENABLE_SCHEDULER=true SEED_DEMO_DATA=false diff --git a/app/api/action/route.ts b/app/api/action/route.ts index 75ff44b..6393bff 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -37,6 +37,13 @@ import { DistributionReleaseError, releaseUnfinishedDistribution, } from "../../../lib/distribution-release-service"; +import { + resolveWecomConfig, + sendWecomAppMessage, + sendWecomRobotMessage, + WecomClientError, + type WecomBindings, +} from "../../../lib/wecom-client"; import { isManagerRequest } from "../../../lib/user-auth"; import { extractPublishUrl } from "../../../lib/publish-url"; @@ -534,6 +541,66 @@ export async function POST(request: Request) { ) .bind(exposure, views, distributionId) .run(); + } else if (body.action === "bind_wecom_external_id") { + const partnerId = String(body.partnerId ?? "").trim().slice(0, 80); + const externalId = String(body.wecomExternalUserId ?? "") + .trim() + .slice(0, 128); + if (!partnerId) { + return Response.json( + { error: "缺少 partnerId" }, + { status: 400 }, + ); + } + await db + .prepare( + "UPDATE partners SET wecom_external_user_id = ? WHERE id = ?", + ) + .bind(externalId || null, partnerId) + .run(); + return Response.json({ + partnerId, + wecomExternalUserId: externalId || null, + }); + } else if (body.action === "send_test_wecom") { + const wecomConfig = resolveWecomConfig( + env as unknown as WecomBindings, + ); + const partnerId = String(body.partnerId ?? "").trim(); + let partnerExternalId: string | null = null; + if (partnerId) { + const row = await db + .prepare( + "SELECT wecom_external_user_id FROM partners WHERE id = ?", + ) + .bind(partnerId) + .first<{ wecom_external_user_id: string | null }>(); + partnerExternalId = row?.wecom_external_user_id ?? null; + } + const testContent = `[KOC LOOP 测试] 群机器人连通性正常,时间 ${new Date().toISOString()}`; + let robotStatus: "ok" | "skipped" = "skipped"; + if (wecomConfig.robotWebhook) { + await sendWecomRobotMessage(testContent, wecomConfig); + robotStatus = "ok"; + } + let appStatus: "ok" | "skipped" | "failed" = "skipped"; + if ( + partnerExternalId && + wecomConfig.corpId && + wecomConfig.agentId && + wecomConfig.secret + ) { + const result = await sendWecomAppMessage( + [partnerExternalId], + testContent, + wecomConfig, + ); + appStatus = result.failed > 0 ? "failed" : "ok"; + } + return Response.json({ + robot: robotStatus, + app: appStatus, + }); } else { return Response.json({ error: "不支持的操作" }, { status: 400 }); } @@ -545,7 +612,8 @@ export async function POST(request: Request) { { status: error instanceof FeishuSourceError || - error instanceof DistributionReleaseError + error instanceof DistributionReleaseError || + error instanceof WecomClientError ? error.status : 500, }, diff --git a/lib/runtime-env.ts b/lib/runtime-env.ts index 9eeb2cc..16cd685 100644 --- a/lib/runtime-env.ts +++ b/lib/runtime-env.ts @@ -20,6 +20,12 @@ export type RuntimeEnv = { AI_TOOL_CENTER_MCP_KEY?: string; COLLECTION_MCP_URL?: string; COLLECTION_MCP_KEY?: string; + WECOM_CORP_ID?: string; + WECOM_AGENT_ID?: string; + WECOM_SECRET?: string; + WECOM_ROBOT_WEBHOOK?: string; + WECOM_NOTIFY_DUE_DAYS?: string; + WECOM_NOTIFY_ENABLED?: string; SEED_DEMO_DATA?: string; ENABLE_SCHEDULER?: string; }; diff --git a/lib/scheduler.ts b/lib/scheduler.ts index 6db9812..2ee6686 100644 --- a/lib/scheduler.ts +++ b/lib/scheduler.ts @@ -8,6 +8,11 @@ import { } from "./mcp-collection-client"; import { ensureSchema, getRawDb } from "./mvp-db"; import { getRuntimeEnv, isEnabled } from "./runtime-env"; +import { + resolveWecomConfig, + type WecomBindings, +} from "./wecom-client"; +import { runDueSoonWecomNotifications } from "./wecom-notifier-service"; declare global { var __kocLoopScheduler: ScheduledTask | undefined; @@ -17,14 +22,31 @@ async function runDailyJob() { await withDatabaseLock("koc-loop-daily-collection", 0, async () => { await ensureSchema(); const db = getRawDb(); + const env = getRuntimeEnv(); const config = resolveCollectionMcpConfig( - getRuntimeEnv() as unknown as CollectionMcpBindings, + env as unknown as CollectionMcpBindings, ); const collections = await runScheduledCollections(db, Date.now(), config); const accounts = await backfillAccountProfiles(db, config, 10); + let wecom: Awaited> | null = + null; + if (isEnabled(env.WECOM_NOTIFY_ENABLED, true)) { + const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); + if ( + wecomConfig.robotWebhook || + (wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret) + ) { + try { + wecom = await runDueSoonWecomNotifications(db, wecomConfig); + } catch (error) { + console.error("[KOC LOOP] wecom notify failed", error); + } + } + } console.info("[KOC LOOP] daily scheduler completed", { collections, accounts, + wecom, }); }); } diff --git a/lib/wecom-client.ts b/lib/wecom-client.ts new file mode 100644 index 0000000..b2aedb0 --- /dev/null +++ b/lib/wecom-client.ts @@ -0,0 +1,219 @@ +const WECOM_API_ORIGIN = "https://qyapi.weixin.qq.com"; +const DEFAULT_DUE_DAYS = 3; + +export type WecomBindings = { + WECOM_CORP_ID?: string; + WECOM_AGENT_ID?: string; + WECOM_SECRET?: string; + WECOM_ROBOT_WEBHOOK?: string; + WECOM_NOTIFY_DUE_DAYS?: string; +}; + +export type WecomConfig = { + corpId: string; + agentId: string; + secret: string; + robotWebhook: string; + dueDays: number; +}; + +type FetchLike = typeof fetch; + +type WecomEnvelope = { + errcode?: number; + errmsg?: string; + access_token?: string; + expires_in?: number; + invaliduser?: string; +}; + +type CachedAccessToken = { + corpId: string; + secret: string; + token: string; + expiresAt: number; +}; + +let cachedAccessToken: CachedAccessToken | null = null; + +export class WecomClientError extends Error { + status: number; + + constructor(message: string, status = 502) { + super(message); + this.name = "WecomClientError"; + this.status = status; + } +} + +function bindingValue(value: unknown) { + return String(value ?? "").trim(); +} + +function safeMessage(value: unknown) { + return String(value ?? "").trim().slice(0, 240); +} + +export function resolveWecomConfig(bindings: WecomBindings): WecomConfig { + return { + corpId: bindingValue(bindings.WECOM_CORP_ID), + agentId: bindingValue(bindings.WECOM_AGENT_ID), + secret: bindingValue(bindings.WECOM_SECRET), + robotWebhook: bindingValue(bindings.WECOM_ROBOT_WEBHOOK), + dueDays: parseDueDays(bindings.WECOM_NOTIFY_DUE_DAYS), + }; +} + +function parseDueDays(value: string | undefined) { + const parsed = Number(bindingValue(value)); + if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_DUE_DAYS; + return Math.min(30, Math.floor(parsed)); +} + +function hasAppCredentials(config: WecomConfig) { + return Boolean(config.corpId && config.agentId && config.secret); +} + +async function readEnvelope( + response: Response, + fallbackMessage: string, +): Promise { + const text = await response.text(); + try { + return JSON.parse(text) as WecomEnvelope; + } catch { + throw new WecomClientError( + `${fallbackMessage}(企业微信返回了非 JSON 响应)`, + 502, + ); + } +} + +function ensureOk( + payload: WecomEnvelope, + fallbackMessage: string, +) { + const code = Number(payload.errcode ?? 0); + if (code === 0) return; + const message = safeMessage(payload.errmsg) || fallbackMessage; + if (code === 40014 || code === 42001) { + throw new WecomClientError(`企业微信 access_token 无效:${message}`, 401); + } + throw new WecomClientError(`${fallbackMessage}(${message})`, 502); +} + +async function fetchAccessToken( + config: WecomConfig, + fetchImpl: FetchLike, +) { + if ( + cachedAccessToken?.corpId === config.corpId && + cachedAccessToken?.secret === config.secret && + cachedAccessToken.expiresAt > Date.now() + 60_000 + ) { + return cachedAccessToken.token; + } + const url = new URL(`${WECOM_API_ORIGIN}/cgi-bin/gettoken`); + url.searchParams.set("corpid", config.corpId); + url.searchParams.set("corpsecret", config.secret); + const response = await fetchImpl(url.toString(), { + method: "GET", + signal: AbortSignal.timeout(12_000), + }); + const payload = await readEnvelope(response, "获取企业微信 access_token 失败"); + if (!response.ok) { + throw new WecomClientError( + `获取企业微信 access_token 失败(HTTP ${response.status})`, + 502, + ); + } + ensureOk(payload, "获取企业微信 access_token 失败"); + const token = bindingValue(payload.access_token); + if (!token) { + throw new WecomClientError("企业微信未返回有效 access_token", 502); + } + cachedAccessToken = { + corpId: config.corpId, + secret: config.secret, + token, + expiresAt: + Date.now() + Math.max(300, Number(payload.expires_in) || 7_200) * 1_000, + }; + return token; +} + +export async function sendWecomRobotMessage( + content: string, + config: WecomConfig, + fetchImpl: FetchLike = fetch, +): Promise { + if (!config.robotWebhook) { + console.warn("[KOC LOOP] wecom robot webhook not configured, skipping"); + return; + } + const response = await fetchImpl(config.robotWebhook, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + msgtype: "text", + text: { content }, + }), + signal: AbortSignal.timeout(10_000), + }); + const payload = await readEnvelope(response, "企业微信群机器人推送失败"); + if (!response.ok) { + throw new WecomClientError( + `企业微信群机器人推送失败(HTTP ${response.status})`, + 502, + ); + } + ensureOk(payload, "企业微信群机器人推送失败"); +} + +export async function sendWecomAppMessage( + externalUserIds: string[], + content: string, + config: WecomConfig, + fetchImpl: FetchLike = fetch, +): Promise<{ sent: number; failed: number; skipped: boolean }> { + const normalized = externalUserIds + .map((id) => bindingValue(id)) + .filter((id) => id.length > 0); + if (normalized.length === 0) { + return { sent: 0, failed: 0, skipped: true }; + } + if (!hasAppCredentials(config)) { + return { sent: 0, failed: 0, skipped: true }; + } + const token = await fetchAccessToken(config, fetchImpl); + const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`; + const response = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + touser: normalized.join("|"), + msgtype: "text", + agentid: Number(config.agentId), + text: { content }, + }), + signal: AbortSignal.timeout(12_000), + }); + const payload = await readEnvelope(response, "企业微信应用消息推送失败"); + if (!response.ok) { + throw new WecomClientError( + `企业微信应用消息推送失败(HTTP ${response.status})`, + 502, + ); + } + ensureOk(payload, "企业微信应用消息推送失败"); + const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean); + return { + sent: Math.max(0, normalized.length - invalid.length), + failed: invalid.length, + skipped: false, + }; +} + +export function clearWecomAccessTokenCacheForTests() { + cachedAccessToken = null; +} diff --git a/lib/wecom-notifier-service.ts b/lib/wecom-notifier-service.ts new file mode 100644 index 0000000..8707c37 --- /dev/null +++ b/lib/wecom-notifier-service.ts @@ -0,0 +1,171 @@ +import type { DatabaseClient } from "./database"; +import { shanghaiDateFromTimestamp } from "./collection-service"; +import { + sendWecomAppMessage, + sendWecomRobotMessage, + type WecomConfig, +} from "./wecom-client"; +import { getRuntimeEnv } from "./runtime-env"; + +type FetchLike = typeof fetch; + +export type WecomNotifySummary = { + dueSoonAttempted: number; + dueSoonSent: number; + dueSoonFailed: number; + dueSoonSkipped: number; + digestSent: boolean; +}; + +type DueSoonRow = { + distribution_id: string; + partner_id: string; + partner_name: string; + wecom_external_user_id: string | null; + task_name: string; + due_at: string; + content_title: string; +}; + +export function computeDueCutoff(today: string, dueDays: number) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today); + if (!match) return today; + const [, y, m, d] = match; + const date = new Date( + Date.UTC(Number(y), Number(m) - 1, Number(d)) + dueDays * 24 * 60 * 60 * 1_000, + ); + return date.toISOString().slice(0, 10); +} + +export async function runDueSoonWecomNotifications( + db: DatabaseClient, + config: WecomConfig, + fetchImpl: FetchLike = fetch, + now: number = Date.now(), +): Promise { + const today = shanghaiDateFromTimestamp(now); + const cutoff = computeDueCutoff(today, config.dueDays); + const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL); + + const result = await db + .prepare( + `SELECT + d.id AS distribution_id, + d.partner_id, + p.name AS partner_name, + p.wecom_external_user_id, + t.name AS task_name, + t.due_at, + c.title AS content_title + FROM distributions d + JOIN tasks t ON t.id = d.task_id + JOIN partners p ON p.id = d.partner_id + JOIN contents c ON c.id = d.content_id + WHERE (d.publish_url IS NULL OR d.publish_url = '') + AND t.due_at IS NOT NULL AND t.due_at != '' + AND t.due_at <= ? + ORDER BY t.due_at ASC, p.name ASC`, + ) + .bind(cutoff) + .all(); + + const grouped = new Map< + string, + { + partnerName: string; + externalUserId: string | null; + rows: DueSoonRow[]; + } + >(); + for (const row of result.results) { + const entry = grouped.get(row.partner_id) ?? { + partnerName: row.partner_name, + externalUserId: row.wecom_external_user_id, + rows: [], + }; + entry.rows.push(row); + if (!entry.externalUserId && row.wecom_external_user_id) { + entry.externalUserId = row.wecom_external_user_id; + } + grouped.set(row.partner_id, entry); + } + + let dueSoonAttempted = 0; + let dueSoonSent = 0; + let dueSoonFailed = 0; + let dueSoonSkipped = 0; + const digestTasks: string[] = []; + + for (const [, entry] of grouped) { + dueSoonAttempted += 1; + const external = entry.externalUserId + ? [entry.externalUserId] + : []; + const lines = entry.rows.slice(0, 5).map((row) => { + return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(截止 ${row.due_at})`; + }); + const overflow = + entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : ""; + const link = portalUrl ? `\n领取链接:${portalUrl}` : ""; + const content = + `${entry.partnerName},你有 ${entry.rows.length} 条内容待发布:\n${lines.join("\n")}${overflow}${link}`; + const appResult = await sendWecomAppMessage( + external, + content, + config, + fetchImpl, + ).catch((error: unknown) => { + console.warn( + "[KOC LOOP] wecom app message failed", + { partner: entry.partnerName, error: safeError(error) }, + ); + return null; + }); + if (appResult === null) { + dueSoonFailed += 1; + } else if (appResult.skipped) { + dueSoonSkipped += 1; + } else { + dueSoonSent += 1; + } + const earliestDue = entry.rows[0]?.due_at ?? ""; + digestTasks.push( + `· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`, + ); + } + + let digestSent = false; + if (config.robotWebhook && digestTasks.length > 0) { + const digest = + `今日待发布催办(${today},截止 ≤ ${cutoff}):\n${digestTasks.join("\n")}`; + try { + await sendWecomRobotMessage(digest, config, fetchImpl); + digestSent = true; + } catch (error) { + console.warn( + "[KOC LOOP] wecom robot digest failed", + { error: safeError(error) }, + ); + } + } + + return { + dueSoonAttempted, + dueSoonSent, + dueSoonFailed, + dueSoonSkipped, + digestSent, + }; +} + +function bindingValue(value: unknown) { + return String(value ?? "").trim(); +} + +function truncate(value: string, max: number) { + return value.length > max ? `${value.slice(0, max)}…` : value; +} + +function safeError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/wecom-client.test.mjs b/tests/wecom-client.test.mjs new file mode 100644 index 0000000..72050e8 --- /dev/null +++ b/tests/wecom-client.test.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + clearWecomAccessTokenCacheForTests, + resolveWecomConfig, + sendWecomAppMessage, + sendWecomRobotMessage, + WecomClientError, +} from "../lib/wecom-client.ts"; + +const baseBindings = { + WECOM_CORP_ID: "corp-test", + WECOM_AGENT_ID: "1000001", + WECOM_SECRET: "secret-test", + WECOM_ROBOT_WEBHOOK: + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=robot-test", + WECOM_NOTIFY_DUE_DAYS: "3", +}; + +function fakeWecom(options = {}) { + const calls = []; + const fetchImpl = async (input, init = {}) => { + const url = new URL(String(input)); + calls.push({ url, init }); + if (url.pathname.endsWith("/cgi-bin/gettoken")) { + return Response.json({ + errcode: 0, + errmsg: "ok", + access_token: "token-test", + expires_in: 7200, + }); + } + if (url.pathname.endsWith("/cgi-bin/webhook/send")) { + return Response.json({ + errcode: options.robotErrcode ?? 0, + errmsg: options.robotErrmsg ?? "ok", + }); + } + if (url.pathname.endsWith("/cgi-bin/message/send")) { + const body = JSON.parse(String(init.body ?? "{}")); + const invalid = options.invalidUser + ? body.touser.split("|").filter((u) => u === options.invalidUser) + : []; + return Response.json({ + errcode: 0, + errmsg: "ok", + invaliduser: invalid.join("|"), + }); + } + return new Response("not found", { status: 404 }); + }; + return { calls, fetchImpl }; +} + +test.beforeEach(() => { + clearWecomAccessTokenCacheForTests(); +}); + +test("resolveWecomConfig applies dueDays and trims values", () => { + const config = resolveWecomConfig({ + WECOM_CORP_ID: " corp ", + WECOM_AGENT_ID: " 10 ", + WECOM_SECRET: " s ", + WECOM_ROBOT_WEBHOOK: " https://hook ", + WECOM_NOTIFY_DUE_DAYS: "5", + }); + assert.equal(config.corpId, "corp"); + assert.equal(config.agentId, "10"); + assert.equal(config.secret, "s"); + assert.equal(config.robotWebhook, "https://hook"); + assert.equal(config.dueDays, 5); +}); + +test("resolveWecomConfig falls back to default dueDays for bad input", () => { + assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "0" }).dueDays, 3); + assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "abc" }).dueDays, 3); + assert.equal( + resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "100" }).dueDays, + 30, + ); +}); + +test("sendWecomRobotMessage posts text payload and returns void", async () => { + const { calls, fetchImpl } = fakeWecom(); + await sendWecomRobotMessage( + "hello", + resolveWecomConfig(baseBindings), + fetchImpl, + ); + const hookCall = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/webhook/send"), + ); + assert.ok(hookCall, "robot webhook was called"); + const body = JSON.parse(String(hookCall.init.body)); + assert.equal(body.msgtype, "text"); + assert.equal(body.text.content, "hello"); +}); + +test("sendWecomRobotMessage skips silently when webhook is empty", async () => { + const { calls, fetchImpl } = fakeWecom(); + const config = resolveWecomConfig({ ...baseBindings, WECOM_ROBOT_WEBHOOK: "" }); + await sendWecomRobotMessage("hello", config, fetchImpl); + assert.equal( + calls.filter((c) => + c.url.pathname.endsWith("/cgi-bin/webhook/send"), + ).length, + 0, + ); +}); + +test("sendWecomRobotMessage throws WecomClientError on provider error", async () => { + const { fetchImpl } = fakeWecom({ + robotErrcode: 93000, + robotErrmsg: "invalid webhook url", + }); + await assert.rejects( + () => + sendWecomRobotMessage( + "hello", + resolveWecomConfig(baseBindings), + fetchImpl, + ), + (error) => { + assert.ok(error instanceof WecomClientError); + assert.equal(error.status, 502); + return true; + }, + ); +}); + +test("sendWecomAppMessage fetches access_token then sends to message/send", async () => { + const { calls, fetchImpl } = fakeWecom(); + const result = await sendWecomAppMessage( + ["user-a", "user-b"], + "催办内容", + resolveWecomConfig(baseBindings), + fetchImpl, + ); + assert.equal(result.sent, 2); + assert.equal(result.failed, 0); + assert.equal(result.skipped, false); + const tokenCall = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/gettoken"), + ); + assert.ok(tokenCall, "gettoken was called"); + const sendCall = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/message/send"), + ); + assert.ok(sendCall, "message/send was called"); + assert.equal(sendCall.url.searchParams.get("access_token"), "token-test"); + const body = JSON.parse(String(sendCall.init.body)); + assert.equal(body.touser, "user-a|user-b"); + assert.equal(body.agentid, 1000001); + assert.equal(body.text.content, "催办内容"); +}); + +test("sendWecomAppMessage skips when no external user ids", async () => { + const { calls, fetchImpl } = fakeWecom(); + const result = await sendWecomAppMessage( + [], + "催办", + resolveWecomConfig(baseBindings), + fetchImpl, + ); + assert.equal(result.skipped, true); + assert.equal( + calls.filter((c) => + c.url.pathname.endsWith("/cgi-bin/message/send"), + ).length, + 0, + ); +}); + +test("sendWecomAppMessage skips when corp credentials are missing", async () => { + const { calls, fetchImpl } = fakeWecom(); + const result = await sendWecomAppMessage( + ["user-a"], + "催办", + resolveWecomConfig({ + ...baseBindings, + WECOM_CORP_ID: "", + WECOM_AGENT_ID: "", + WECOM_SECRET: "", + }), + fetchImpl, + ); + assert.equal(result.skipped, true); + assert.equal( + calls.filter((c) => + c.url.pathname.endsWith("/cgi-bin/gettoken"), + ).length, + 0, + ); +}); + +test("sendWecomAppMessage reports failed when provider marks invaliduser", async () => { + const { fetchImpl } = fakeWecom({ invalidUser: "user-a" }); + const result = await sendWecomAppMessage( + ["user-a", "user-b"], + "催办", + resolveWecomConfig(baseBindings), + fetchImpl, + ); + assert.equal(result.sent, 1); + assert.equal(result.failed, 1); +}); + +test("access_token is cached across calls", async () => { + const { calls, fetchImpl } = fakeWecom(); + const config = resolveWecomConfig(baseBindings); + await sendWecomAppMessage(["u1"], "a", config, fetchImpl); + await sendWecomAppMessage(["u2"], "b", config, fetchImpl); + const tokenCalls = calls.filter((c) => + c.url.pathname.endsWith("/cgi-bin/gettoken"), + ); + assert.equal(tokenCalls.length, 1, "access_token cached for second call"); +}); diff --git a/tests/wecom-notifier.test.mjs b/tests/wecom-notifier.test.mjs new file mode 100644 index 0000000..6250993 --- /dev/null +++ b/tests/wecom-notifier.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { computeDueCutoff } from "../lib/wecom-notifier-service.ts"; + +test("computeDueCutoff advances by dueDays and crosses month/year", () => { + assert.equal(computeDueCutoff("2026-08-18", 3), "2026-08-21"); + assert.equal(computeDueCutoff("2026-08-30", 3), "2026-09-02"); + assert.equal(computeDueCutoff("2026-12-30", 3), "2027-01-02"); + assert.equal(computeDueCutoff("2026-08-18", 0), "2026-08-18"); + assert.equal(computeDueCutoff("2026-08-18", 10), "2026-08-28"); +}); + +test("computeDueCutoff returns input unchanged when malformed", () => { + assert.equal(computeDueCutoff("not-a-date", 3), "not-a-date"); +});