feat: R27-1 收口企业微信入口配置状态

这个提交包含在:
2026-03-22 19:52:39 +08:00
父节点 08ae013b9e
当前提交 eaef213d22
共修改 4 个文件,包含 338 行新增和 25 行删除
+6 -2
查看文件
@@ -17,15 +17,19 @@ export async function changePassword(payload: { oldPassword: string; newPassword
});
}
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
export type WecomEntryConfig = {
enabled: boolean;
entryEnabled: boolean;
oauthEnabled: boolean;
corpId: string;
agentId: string;
oauthEnabled: boolean;
loginUrl: string | null;
bindEnabled: boolean;
defaultReturnTo: "/portal";
status: "ready" | "disabled" | "misconfigured";
statusReasons: string[];
};
export async function fetchSsoProviders() {
@@ -0,0 +1,8 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
R27-0,执行合同建档与范围冻结,为企业微信正式接入与首批交互优化建立执行合同并冻结本轮范围,Issue CSV 校验通过 且范围锁定为企微入口 登录绑定 Portal 导航与测试文档,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,manual,DONE,DONE,DONE,issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,[],none
R27-1,企业微信接入配置与入口链路收口,扩展 wecom config 返回状态与原因 将 WECOM_ENTRY_ENABLED 纳入正式入口判断并保持现有 login bind callback 主链路不变,GET /api/auth/wecom/config 返回 decision complete 状态结构 且未绑定回退与白名单 returnTo 逻辑零回退,cd server && npm test,manual,DONE,DONE,DONE,server/src/routes/wecomAuth.js|client/src/api/auth.ts|server/tests/unit/routes/wecomAuth.test.js,"[""R27-0""]",不新增新路由 仅扩展 config 返回
R27-2,登录页与个人绑定体验优化,重构 LoginPage 与 ProfilePage 让企微入口 绑定状态 回退提示更产品化,普通浏览器与企微场景 CTA 分层明确 wecom query 提示可读 个人设置页可清晰展示接入与绑定状态,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/LoginPage.tsx|client/src/pages/ProfilePage.tsx|client/src/api/auth.ts,"[""R27-1""]",不新增页面 继续复用现有登录页与个人设置页
R27-3,Portal 首屏信息架构重排,重排 Portal 首屏卡片与摘要层级 让员工与管理员优先看到今日动作与关键提醒,员工与管理员首屏主卡片顺序符合规划 观测摘要对普通员工弱化 公告降为第二阅读层,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx,"[""R27-1""]",仅做信息架构与文案层级优化 不改后端 portal payload 结构
R27-4,导航 Topbar 与备用模块弱化,优化 NavLinks Sidebar Topbar 的分区展示与企微入口提示 弱化备用模块存在感,主导航稳定为 9 项 管理入口单独分组 备用模块默认折叠 顶栏在企微 WebView 下显示入口提示,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/components/navigation/NavLinks.tsx|client/src/components/navigation/Sidebar.tsx|client/src/components/navigation/Topbar.tsx,"[""R27-3""]",不改后端导航接口 以前端分组与展示优化为主
R27-5,自动化测试与文档补强,补齐 wecom 后端单测 登录与 Portal 交互 E2E 并同步管理员与员工文档,新增或扩展测试覆盖 wecom config 登录提示 Portal 顺序与导航弱化 文档写清配置与使用方式,cd server && npm test && cd ../client && npm run test:e2e,manual,DOING,TODO,TODO,server/tests/unit/routes/wecomAuth.test.js|client/tests/e2e/wecom-entry.spec.ts|client/tests/e2e/login-portal-ux.spec.ts|docs/admin-handbook.md|docs/employee-quickstart.md,"[""R27-2"",""R27-3"",""R27-4""]",新增 wecom-entry/login-portal-ux 用例已通过;client 全量 test:e2e 被既有 assets-flow 与 confidential-boundary 失败阻塞
R27-6,试点验收与上线清单收口,完成本地验证 部署抽检 文档清单同步与 git 提交 形成可进入小范围企微试点的版本,关键单测 类型检查 构建 E2E 通过 且接入清单可供管理员逐项配置实施,cd server && npm test && cd ../client && npm run typecheck && npm run build && npm run test:e2e,manual,DOING,TODO,TODO,deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,"[""R27-5""]",本地 server 全量单测 250/250 通过;client typecheck/build 通过;全量 E2E 仍受既有失败阻塞
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 R27-0 执行合同建档与范围冻结 为企业微信正式接入与首批交互优化建立执行合同并冻结本轮范围 Issue CSV 校验通过 且范围锁定为企微入口 登录绑定 Portal 导航与测试文档 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv manual DONE DONE DONE issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv [] none
3 R27-1 企业微信接入配置与入口链路收口 扩展 wecom config 返回状态与原因 将 WECOM_ENTRY_ENABLED 纳入正式入口判断并保持现有 login bind callback 主链路不变 GET /api/auth/wecom/config 返回 decision complete 状态结构 且未绑定回退与白名单 returnTo 逻辑零回退 cd server && npm test manual DONE DONE DONE server/src/routes/wecomAuth.js|client/src/api/auth.ts|server/tests/unit/routes/wecomAuth.test.js ["R27-0"] 不新增新路由 仅扩展 config 返回
4 R27-2 登录页与个人绑定体验优化 重构 LoginPage 与 ProfilePage 让企微入口 绑定状态 回退提示更产品化 普通浏览器与企微场景 CTA 分层明确 wecom query 提示可读 个人设置页可清晰展示接入与绑定状态 cd client && npm run typecheck && npm run build manual DONE DONE DONE client/src/pages/LoginPage.tsx|client/src/pages/ProfilePage.tsx|client/src/api/auth.ts ["R27-1"] 不新增页面 继续复用现有登录页与个人设置页
5 R27-3 Portal 首屏信息架构重排 重排 Portal 首屏卡片与摘要层级 让员工与管理员优先看到今日动作与关键提醒 员工与管理员首屏主卡片顺序符合规划 观测摘要对普通员工弱化 公告降为第二阅读层 cd client && npm run typecheck && npm run build manual DONE DONE DONE client/src/pages/PortalHomePage.tsx ["R27-1"] 仅做信息架构与文案层级优化 不改后端 portal payload 结构
6 R27-4 导航 Topbar 与备用模块弱化 优化 NavLinks Sidebar Topbar 的分区展示与企微入口提示 弱化备用模块存在感 主导航稳定为 9 项 管理入口单独分组 备用模块默认折叠 顶栏在企微 WebView 下显示入口提示 cd client && npm run typecheck && npm run build manual DONE DONE DONE client/src/components/navigation/NavLinks.tsx|client/src/components/navigation/Sidebar.tsx|client/src/components/navigation/Topbar.tsx ["R27-3"] 不改后端导航接口 以前端分组与展示优化为主
7 R27-5 自动化测试与文档补强 补齐 wecom 后端单测 登录与 Portal 交互 E2E 并同步管理员与员工文档 新增或扩展测试覆盖 wecom config 登录提示 Portal 顺序与导航弱化 文档写清配置与使用方式 cd server && npm test && cd ../client && npm run test:e2e manual DOING TODO TODO server/tests/unit/routes/wecomAuth.test.js|client/tests/e2e/wecom-entry.spec.ts|client/tests/e2e/login-portal-ux.spec.ts|docs/admin-handbook.md|docs/employee-quickstart.md ["R27-2","R27-3","R27-4"] 新增 wecom-entry/login-portal-ux 用例已通过;client 全量 test:e2e 被既有 assets-flow 与 confidential-boundary 失败阻塞
8 R27-6 试点验收与上线清单收口 完成本地验证 部署抽检 文档清单同步与 git 提交 形成可进入小范围企微试点的版本 关键单测 类型检查 构建 E2E 通过 且接入清单可供管理员逐项配置实施 cd server && npm test && cd ../client && npm run typecheck && npm run build && npm run test:e2e manual DOING TODO TODO deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv ["R27-5"] 本地 server 全量单测 250/250 通过;client typecheck/build 通过;全量 E2E 仍受既有失败阻塞
+81 -23
查看文件
@@ -9,6 +9,8 @@ const { sendData } = require("../utils/reply");
const BIND_COOKIE_NAME = "wecom_bind";
const DATE_WINDOW_MS = 10 * 60 * 1000;
const DEFAULT_RETURN_TO = "/portal";
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
const QUERY_SCHEMA = z.object({
returnTo: z.string().optional(),
mode: z.enum(["login", "bind"]).optional(),
@@ -58,7 +60,6 @@ function parseAllowedReturnHosts(config, request) {
try {
hosts.add(new URL(item).host);
} catch {
// ignore invalid origin
}
}
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
@@ -68,7 +69,6 @@ function parseAllowedReturnHosts(config, request) {
try {
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
} catch {
// ignore
}
}
return hosts;
@@ -76,39 +76,102 @@ function parseAllowedReturnHosts(config, request) {
function sanitizeReturnTo(rawValue, config, request) {
if (typeof rawValue !== "string") {
return "/portal";
return DEFAULT_RETURN_TO;
}
const trimmed = rawValue.trim();
if (!trimmed) {
return "/portal";
return DEFAULT_RETURN_TO;
}
if (trimmed.startsWith("/")) {
return trimmed.startsWith("//") ? "/portal" : trimmed;
return trimmed.startsWith("//") ? DEFAULT_RETURN_TO : trimmed;
}
try {
const url = new URL(trimmed);
const allowedHosts = parseAllowedReturnHosts(config, request);
if (!allowedHosts.has(url.host)) {
return "/portal";
return DEFAULT_RETURN_TO;
}
return `${url.pathname || "/portal"}${url.search || ""}${url.hash || ""}`;
return `${url.pathname || DEFAULT_RETURN_TO}${url.search || ""}${url.hash || ""}`;
} catch {
return "/portal";
return DEFAULT_RETURN_TO;
}
}
function assertWecomEnabled(config) {
function resolveWecomEntryConfig(config) {
const statusReasons = [];
if (!config.wecomEnabled) {
statusReasons.push("wecom_disabled");
}
if (!config.wecomEntryEnabled) {
statusReasons.push("entry_disabled");
}
if (!config.wecomOauthEnabled) {
statusReasons.push("oauth_disabled");
}
if (!config.wecomCorpId) {
statusReasons.push("corp_id_missing");
}
if (!config.wecomAgentId) {
statusReasons.push("agent_id_missing");
}
if (!config.wecomRedirectUri) {
statusReasons.push("redirect_uri_missing");
}
const bindEnabled = Boolean(
config.wecomEnabled
&& config.wecomOauthEnabled
&& config.wecomCorpId
&& config.wecomAgentId
&& config.wecomRedirectUri,
);
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
return {
enabled: entryEnabled,
entryEnabled,
oauthEnabled: Boolean(config.wecomOauthEnabled),
corpId: String(config.wecomCorpId ?? ""),
agentId: String(config.wecomAgentId ?? ""),
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
bindEnabled,
defaultReturnTo: DEFAULT_RETURN_TO,
status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured",
statusReasons,
};
}
function assertWecomOauthReady(config) {
const entryConfig = resolveWecomEntryConfig(config);
if (!config.wecomEnabled || !config.wecomOauthEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!config.wecomCorpId || !config.wecomAgentId || !config.wecomRedirectUri) {
if (!entryConfig.bindEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { wecom: "missing_required_config" },
details: { wecom: "missing_required_config", statusReasons: entryConfig.statusReasons },
});
}
return entryConfig;
}
function assertWecomEntryReady(config) {
const entryConfig = assertWecomOauthReady(config);
if (!config.wecomEntryEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return entryConfig;
}
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
const params = new URLSearchParams({ returnTo });
if (mode === "bind") {
params.set("mode", "bind");
}
return `/api/auth/wecom/login?${params.toString()}`;
}
async function getWecomAccessToken(config) {
@@ -218,30 +281,25 @@ async function createAuthenticatedSession({ prisma, request, config, employeeId,
async function wecomAuthRoutes(fastify) {
fastify.get("/wecom/config", async (request, reply) => {
const enabled = Boolean(fastify.config.wecomEnabled && fastify.config.wecomOauthEnabled && fastify.config.wecomCorpId && fastify.config.wecomAgentId && fastify.config.wecomRedirectUri);
return sendData(reply, request, {
enabled,
corpId: enabled ? fastify.config.wecomCorpId : "",
agentId: enabled ? fastify.config.wecomAgentId : "",
oauthEnabled: enabled,
loginUrl: enabled ? "/api/auth/wecom/login?returnTo=%2Fportal" : null,
});
return sendData(reply, request, resolveWecomEntryConfig(fastify.config));
});
fastify.get("/wecom/login", async (request, reply) => {
assertWecomEnabled(fastify.config);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
}
const mode = parsed.data.mode ?? "login";
if (mode === "bind") {
assertWecomOauthReady(fastify.config);
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
const bindState = verifyToken(bindCookie, fastify.config.jwtSecret);
if (!bindState.ok) {
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
} else {
assertWecomEntryReady(fastify.config);
}
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
const state = signPayload({
@@ -254,7 +312,7 @@ async function wecomAuthRoutes(fastify) {
});
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
assertWecomEnabled(fastify.config);
assertWecomOauthReady(fastify.config);
const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
@@ -267,7 +325,7 @@ async function wecomAuthRoutes(fastify) {
}, fastify.config.jwtSecret);
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
return sendData(reply, request, {
loginUrl: `/api/auth/wecom/login?mode=bind&returnTo=${encodeURIComponent(returnTo)}`,
loginUrl: buildWecomLoginUrl(returnTo, "bind"),
});
});
@@ -290,7 +348,7 @@ async function wecomAuthRoutes(fastify) {
});
fastify.get("/wecom/callback", async (request, reply) => {
assertWecomEnabled(fastify.config);
assertWecomOauthReady(fastify.config);
const stateToken = String(request.query?.state ?? "").trim();
const code = String(request.query?.code ?? "").trim();
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
+243
查看文件
@@ -0,0 +1,243 @@
const Fastify = require("fastify");
const fastifyCookie = require("@fastify/cookie");
const { AppError } = require("../../../src/utils/AppError");
const wecomAuthRoutes = require("../../../src/routes/wecomAuth");
const { signPayload, verifyToken } = require("../../../src/utils/signedUrl");
const NOW = new Date("2026-03-22T11:00:00.000Z");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const JWT_SECRET = "test-wecom-secret";
const originalFetch = global.fetch;
function buildPrisma(overrides = {}) {
return {
wecomIdentityBinding: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
...(overrides.wecomIdentityBinding ?? {}),
},
employee: {
findUnique: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, status: "active" }),
...(overrides.employee ?? {}),
},
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
...(overrides.auditLog ?? {}),
},
...overrides,
};
}
async function buildApp({ config = {}, prisma = buildPrisma() } = {}) {
const app = Fastify();
await app.register(fastifyCookie);
app.decorate("config", {
appOrigin: "https://oa.hyinda.com",
jwtSecret: JWT_SECRET,
refreshCookieName: "refresh_token",
refreshTtlSeconds: 7 * 24 * 60 * 60,
cookieSecure: true,
wecomEnabled: true,
wecomEntryEnabled: true,
wecomOauthEnabled: true,
wecomCorpId: "wx-test-corp",
wecomAgentId: "1000001",
wecomAgentSecret: "wecom-secret",
wecomRedirectUri: "https://oa.hyinda.com/api/auth/wecom/callback",
wecomAllowedReturnHosts: "oa.hyinda.com",
...config,
});
app.decorate("prisma", prisma);
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = { id: EMPLOYEE_ID };
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
return app;
}
function mockWecomFetch({ userId = "wecom-user-1", openId = "wecom-open-1" } = {}) {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, UserId: userId, OpenId: openId, DeviceId: "device-1" }),
});
}
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("wecom auth routes R27", () => {
it("returns entry-disabled config while keeping bind available", async () => {
const app = await buildApp({ config: { wecomEntryEnabled: false } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
enabled: false,
entryEnabled: false,
bindEnabled: true,
oauthEnabled: true,
status: "disabled",
defaultReturnTo: "/portal",
loginUrl: null,
statusReasons: expect.arrayContaining(["entry_disabled"]),
}));
await app.close();
});
it("returns misconfigured status when corp id is missing", async () => {
const app = await buildApp({ config: { wecomCorpId: "" } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
enabled: false,
bindEnabled: false,
status: "misconfigured",
statusReasons: expect.arrayContaining(["corp_id_missing"]),
corpId: "",
agentId: "1000001",
}));
await app.close();
});
it("sanitizes login returnTo to allowed hosts or local paths", async () => {
const app = await buildApp();
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?returnTo=https%3A%2F%2Fevil.example.com%2Fsteal",
});
expect(response.statusCode).toBe(302);
const location = response.headers.location;
expect(location).toContain("open.weixin.qq.com/connect/oauth2/authorize");
const state = new URL(location).searchParams.get("state");
const verified = verifyToken(state, JWT_SECRET);
expect(verified.ok).toBe(true);
expect(verified.payload.returnTo).toBe("/portal");
expect(verified.payload.mode).toBe("login");
await app.close();
});
it("redirects unbound users back to login with wecom hint", async () => {
mockWecomFetch({ userId: "wecom-unbound" });
const prisma = buildPrisma({
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-login",
returnTo: "/portal",
mode: "login",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
await app.close();
});
it("creates bind audit log on successful wecom bind callback", async () => {
mockWecomFetch({ userId: "wecom-bind-1" });
const prisma = buildPrisma();
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-bind",
returnTo: "/settings/profile",
mode: "bind",
}, JWT_SECRET);
const bindCookie = signPayload({
exp: Date.now() + 60_000,
employeeId: EMPLOYEE_ID,
returnTo: "/settings/profile",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: {
cookie: `wecom_bind=${bindCookie}`,
},
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/settings/profile?wecomBind=success");
expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled();
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
actor_id: EMPLOYEE_ID,
action: "AUTH_WECOM_BIND",
target_type: "employee",
target_id: EMPLOYEE_ID,
}),
}));
await app.close();
});
it("writes audit log when unbinding wecom identity", async () => {
const prisma = buildPrisma();
const app = await buildApp({ prisma });
const response = await app.inject({
method: "POST",
url: "/api/auth/wecom/unbind",
headers: {
authorization: "Bearer test-token",
},
payload: {},
});
expect(response.statusCode).toBe(200);
expect(prisma.wecomIdentityBinding.deleteMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID } });
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
actor_id: EMPLOYEE_ID,
action: "AUTH_WECOM_UNBIND",
}),
}));
await app.close();
});
});