feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑

- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
This commit is contained in:
ABAPPLO
2026-09-04 18:03:46 +08:00
parent 5430624a43
commit dd5b603149
44 changed files with 4655 additions and 2039 deletions

View File

@@ -1,23 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
projectMainChatId,
deleteProject,
ensureProjectMainChat,
effectiveProjectMembers,
findOrCreateGroupOwnerPartner,
getProjectDetail,
listProjects,
parseMemberIds,
ProjectError,
saveGroupOwnerPartner,
saveProject,
setProjectGroupChats,
} from "../lib/project-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
} from "../lib/wecom-client.ts";
function fakeProjectDb({
projects = [],
links = [],
tasks = [],
groupChats = [],
partners = [],
} = {}) {
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
function fakeWecom(options = {}) {
const calls = [];
const chats = new Map(options.chats ?? []);
const failCreate = options.failCreate ?? false;
const failGet = options.failGet ?? false;
const createExisted = options.createExisted ?? false;
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/appchat/create")) {
if (createExisted) {
return Response.json({ errcode: 86215, errmsg: "chatid existed" });
}
if (failCreate) {
return Response.json({ errcode: 60111, errmsg: "user not in scope" });
}
const body = JSON.parse(String(init.body ?? "{}"));
chats.set(body.chatid, { name: body.name, userlist: body.userlist });
return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid });
}
if (url.pathname.endsWith("/cgi-bin/appchat/update")) {
const body = JSON.parse(String(init.body ?? "{}"));
const chat = chats.get(body.chatid);
if (!chat) {
return Response.json({ errcode: 60111, errmsg: "chat not found" });
}
for (const userid of body.add_user_list ?? []) {
if (!chat.userlist.includes(userid)) chat.userlist.push(userid);
}
for (const userid of body.del_user_list ?? []) {
chat.userlist = chat.userlist.filter((id) => id !== userid);
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
if (url.pathname.endsWith("/cgi-bin/appchat/get")) {
if (failGet) {
return Response.json({ errcode: 86001, errmsg: "chatid invalid" });
}
const body = JSON.parse(String(init.body ?? "{}"));
const chat = chats.get(body.chatid);
if (!chat) {
return Response.json({ errcode: 60111, errmsg: "chat not found" });
}
return Response.json({
errcode: 0,
errmsg: "ok",
chat_info: {
chatid: body.chatid,
name: chat.name,
userlist: chat.userlist,
},
});
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl, chats };
}
function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
const executed = [];
const applyWrite = (sql, params) => {
const normalized = sql.trim();
@@ -28,12 +105,18 @@ function fakeProjectDb({
brand: params[2],
main_group_member_ids: params[3],
owner_userid: params[4],
main_chat_id: "",
status: "active",
note: params[5],
created_at: params[6],
});
return 1;
}
if (/^UPDATE projects SET main_chat_id/.test(normalized)) {
const row = projects.find((item) => item.id === params[1]);
if (row) row.main_chat_id = params[0];
return row ? 1 : 0;
}
if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[5]);
if (!row) return 0;
@@ -52,16 +135,6 @@ function fakeProjectDb({
projects.splice(index, 1);
return 1;
}
if (/^INSERT INTO project_group_chats/.test(normalized)) {
links.push({ project_id: params[1], chat_id: params[2], partner_id: params[3] });
return 1;
}
if (/^DELETE FROM project_group_chats/.test(normalized)) {
for (let index = links.length - 1; index >= 0; index -= 1) {
if (links[index].project_id === params[0]) links.splice(index, 1);
}
return 1;
}
if (/^UPDATE tasks SET project_id = ''/.test(normalized)) {
for (const task of tasks) {
if (task.project_id === params[0]) task.project_id = "";
@@ -103,6 +176,15 @@ function fakeProjectDb({
if (/FROM partners WHERE id = \?/.test(sql)) {
return partners.find((item) => item.id === params[0]) ?? null;
}
if (/partner_type = 'group_owner' AND wecom_userid = \?/.test(sql)) {
return (
partners.find(
(item) =>
item.partner_type === "group_owner" &&
item.wecom_userid === params[0],
) ?? null
);
}
return null;
},
async all() {
@@ -112,7 +194,6 @@ function fakeProjectDb({
results: projects.map((item) => ({
...item,
task_count: tasks.filter((task) => task.project_id === item.id).length,
group_chat_count: links.filter((link) => link.project_id === item.id).length,
})),
};
}
@@ -123,25 +204,6 @@ function fakeProjectDb({
.map((task) => ({ ...task })),
};
}
if (/FROM project_group_chats g/.test(sql)) {
return {
results: links
.filter((link) => link.project_id === params[0])
.map((link) => {
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
return {
chat_id: link.chat_id,
partner_id: link.partner_id,
chat_name: chat ? chat.name : null,
owner_user_id: chat ? chat.owner_user_id : null,
member_count: chat ? chat.member_count : null,
chat_status: chat ? chat.status : null,
partner_name:
partners.find((item) => item.id === link.partner_id)?.name ?? null,
};
}),
};
}
if (/FROM partners/.test(sql)) {
return { results: partners.map((item) => ({ ...item })) };
}
@@ -150,9 +212,7 @@ function fakeProjectDb({
});
return {
projects,
links,
tasks,
groupChats,
partners,
executed,
prepare(sql) {
@@ -166,6 +226,19 @@ function fakeProjectDb({
};
}
const projectRow = (overrides = {}) => ({
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
owner_userid: "",
main_chat_id: "",
status: "active",
note: "",
created_at: "2026-08-01",
...overrides,
});
test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => {
assert.deepEqual(parseMemberIds("zhangsan\nlisi zhangsan"), ["zhangsan", "lisi"]);
assert.deepEqual(parseMemberIds(["a", " b ", "a"]), ["a", "b"]);
@@ -173,9 +246,18 @@ test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () =>
assert.deepEqual(parseMemberIds(" \n "), []);
});
test("saveProject inserts with proj- prefixed id and JSON members", async () => {
test("effectiveProjectMembers parses stored JSON and merges owner", () => {
assert.deepEqual(
effectiveProjectMembers('["u1","u2"]', "owner-a"),
["u1", "u2", "owner-a"],
);
assert.deepEqual(effectiveProjectMembers("u1\nu1,u2", ""), ["u1", "u2"]);
assert.deepEqual(effectiveProjectMembers('["u1"]', "u1"), ["u1"]);
});
test("saveProject inserts with proj- prefixed id and JSON members incl owner", async () => {
const db = fakeProjectDb();
const project = await saveProject(db, {
const { project, mainChat } = await saveProject(db, {
name: "暑期焕新",
brand: "青柠实验室",
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
@@ -185,24 +267,20 @@ test("saveProject inserts with proj- prefixed id and JSON members", async () =>
assert.match(project.id, /^proj-/);
assert.equal(project.name, "暑期焕新");
assert.equal(project.ownerUserid, "owner-zhang");
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]);
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi", "owner-zhang"]);
assert.equal(db.projects.length, 1);
assert.equal(db.projects[0].status, "active");
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [
"zhangsan",
"lisi",
"owner-zhang",
]);
assert.equal(mainChat.reason, "no_credentials", "no config → lazy chat");
});
test("saveProject updates existing row and rejects blank name", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "旧名",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
projects: [projectRow({ id: "proj-1", name: "旧名" })],
});
await saveProject(db, {
id: "proj-1",
@@ -214,7 +292,10 @@ test("saveProject updates existing row and rejects blank name", async () => {
assert.equal(db.projects[0].name, "新名");
assert.equal(db.projects[0].brand, "品牌");
assert.equal(db.projects[0].owner_userid, "owner-li");
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), ["wangwu"]);
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [
"wangwu",
"owner-li",
]);
assert.equal(db.projects.length, 1, "update must not insert");
await assert.rejects(
@@ -231,63 +312,81 @@ test("saveProject rejects unknown id on update", async () => {
);
});
test("listProjects aggregates task and group chat counts", async () => {
test("saveProject with config creates main chat once members reach 3", async () => {
const db = fakeProjectDb();
const wecom = fakeWecom();
const { project, mainChat } = await saveProject(
db,
{
name: "满三人群",
mainGroupMemberIds: "u1\nu2",
ownerUserid: "owner-a",
},
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(mainChat.chatId, projectMainChatId(project.id));
assert.equal(mainChat.reason, undefined);
assert.equal(db.projects[0].main_chat_id, projectMainChatId(project.id));
const createBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.init.body,
),
);
assert.equal(createBody.chatid, projectMainChatId(project.id));
assert.deepEqual(createBody.userlist.sort(), ["owner-a", "u1", "u2"]);
});
test("listProjects aggregates task counts and exposes mainChatId", async () => {
const db = fakeProjectDb({
projects: [
{
projectRow({
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[\"u1\",\"u2\"]",
status: "active",
note: "",
created_at: "2026-08-01",
},
main_group_member_ids: '["u1","u2"]',
main_chat_id: "pgcproj1",
}),
],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-1" },
{ id: "t3", project_id: "" },
],
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
});
const projects = await listProjects(db);
assert.equal(projects.length, 1);
assert.equal(projects[0].taskCount, 2);
assert.equal(projects[0].groupChatCount, 1);
assert.equal(projects[0].mainChatId, "pgcproj1");
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
});
test("getProjectDetail marks missing chats and dangling partners", async () => {
test("getProjectDetail returns tasks with owner and mainChat members", async () => {
const db = fakeProjectDb({
projects: [
{
projectRow({
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
main_group_member_ids: '["u1"]',
owner_userid: "owner-a",
main_chat_id: "pgcproj1",
}),
],
tasks: [
{
id: "t1",
name: "任务一",
project_id: "proj-1",
owner_userid: "owner-a",
task_chat_id: "tgc_t1",
},
],
links: [
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" },
],
groupChats: [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
],
partners: [],
});
const detail = await getProjectDetail(db, "proj-1");
assert.equal(detail.groupChats.length, 2);
const alive = detail.groupChats.find((chat) => chat.chatId === "wr-1");
assert.equal(alive.missing, false);
assert.equal(alive.partnerName, "", "dangling partner shows blank name");
const gone = detail.groupChats.find((chat) => chat.chatId === "wr-gone");
assert.equal(gone.missing, true);
assert.equal(gone.name, "");
assert.equal(detail.project.taskCount, 1);
assert.equal(detail.project.mainChatId, "pgcproj1");
assert.equal(detail.tasks[0].owner_userid, "owner-a");
assert.equal(detail.tasks[0].task_chat_id, "tgc_t1");
assert.equal(detail.mainChat.chatId, "pgcproj1");
assert.deepEqual(detail.mainChat.members, ["u1", "owner-a"]);
});
test("getProjectDetail throws 404 for unknown project", async () => {
@@ -298,51 +397,9 @@ test("getProjectDetail throws 404 for unknown project", async () => {
);
});
test("setProjectGroupChats replaces links and dedupes chat ids", async () => {
test("deleteProject unlinks tasks", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [{ project_id: "proj-1", chat_id: "wr-old", partner_id: "" }],
groupChats: [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
],
partners: [{ id: "p-1", name: "张三", wecom_userid: "zhangsan" }],
});
const detail = await setProjectGroupChats(db, "proj-1", [
{ chatId: "wr-1", partnerId: "p-1" },
{ chatId: "wr-1", partnerId: "p-1" },
{ chatId: " ", partnerId: "" },
]);
assert.equal(detail.groupChats.length, 1);
assert.equal(detail.groupChats[0].chatId, "wr-1");
assert.equal(detail.groupChats[0].partnerName, "张三");
assert.equal(db.links.length, 1);
assert.equal(db.links[0].chat_id, "wr-1");
});
test("deleteProject removes links and unlinks tasks", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
projects: [projectRow({ id: "proj-1" })],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-2" },
@@ -350,7 +407,6 @@ test("deleteProject removes links and unlinks tasks", async () => {
});
await deleteProject(db, "proj-1");
assert.equal(db.projects.length, 0);
assert.equal(db.links.length, 0);
assert.equal(db.tasks[0].project_id, "");
assert.equal(db.tasks[1].project_id, "proj-2");
@@ -360,6 +416,216 @@ test("deleteProject removes links and unlinks tasks", async () => {
);
});
test("ensureProjectMainChat returns no_project for unknown ids", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb();
const result = await ensureProjectMainChat(
db,
"proj-404",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.reason, "no_project");
assert.equal(result.chatId, null);
});
test("ensureProjectMainChat skips without app credentials", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb({
projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
wecom.fetchImpl,
);
assert.equal(result.reason, "no_credentials");
assert.equal(result.chatId, null);
assert.equal(wecom.calls.length, 0);
});
test("ensureProjectMainChat skips creation when fewer than 3 members", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb({
projects: [projectRow({ main_group_member_ids: '["u1","u2"]' })],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.reason, "not_enough_members");
assert.equal(result.chatId, null);
assert.equal(db.projects[0].main_chat_id, "");
assert.equal(
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.length,
0,
);
});
test("ensureProjectMainChat probes before creating and persists chat id", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb({
projects: [
projectRow({
name: "项目一",
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.deepEqual(result.members.sort(), ["owner-a", "u1", "u2"]);
assert.equal(db.projects[0].main_chat_id, "pgcproj1");
const paths = wecom.calls.map((c) => c.url.pathname);
assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/get")), "probe first");
assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/create")));
assert.ok(
paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/get"))) <
paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/create"))),
"probe precedes create",
);
});
test("ensureProjectMainChat is idempotent when chat already exists remotely", async () => {
const wecom = fakeWecom({
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "u2", "owner-a"] }]],
});
const db = fakeProjectDb({
projects: [
projectRow({
name: "项目一",
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.equal(db.projects[0].main_chat_id, "pgcproj1", "id persisted on recovery");
assert.equal(
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.length,
0,
"no duplicate create",
);
});
test("ensureProjectMainChat diffs members add and del on existing chat", async () => {
const wecom = fakeWecom({
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]],
});
const db = fakeProjectDb({
projects: [
projectRow({
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
main_chat_id: "pgcproj1",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
const updateBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"))
.init.body,
),
);
assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "u2"]);
assert.deepEqual(updateBody.del_user_list, ["gone-user"]);
});
test("ensureProjectMainChat reports warning and keeps main_chat_id empty on create failure", async () => {
const wecom = fakeWecom({ failCreate: true });
const db = fakeProjectDb({
projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, null);
assert.match(result.warning, /创建失败/);
assert.equal(db.projects[0].main_chat_id, "", "chat id not persisted on failure");
});
test("ensureProjectMainChat adopts chat when create reports chatid existed", async () => {
// 可见范围非根部门时 appchat/get 永远 86001,但群实际已建过(create 返回 86215)
const wecom = fakeWecom({ failGet: true, createExisted: true });
const db = fakeProjectDb({
projects: [
projectRow({
name: "项目一",
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.equal(result.warning, undefined);
assert.equal(db.projects[0].main_chat_id, "pgcproj1", "existing chat id adopted");
});
test("ensureProjectMainChat falls back to add-only sync when get is unavailable", async () => {
const wecom = fakeWecom({
failGet: true,
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]],
});
const db = fakeProjectDb({
projects: [
projectRow({
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
main_chat_id: "pgcproj1",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.equal(result.warning, undefined);
const updateBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"))
.init.body,
),
);
assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "u1", "u2"]);
assert.equal(updateBody.del_user_list, undefined, "no removals without member list");
});
test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => {
const db = fakeProjectDb();
const created = await saveGroupOwnerPartner(db, {
@@ -382,3 +648,23 @@ test("saveGroupOwnerPartner inserts and updates group_owner partners", async ()
(error) => error instanceof ProjectError,
);
});
test("findOrCreateGroupOwnerPartner returns existing row without duplicating", async () => {
const db = fakeProjectDb({
partners: [
{ id: "partner-go-old", name: "负责人", wecom_userid: "owner-a", partner_type: "group_owner" },
],
});
const found = await findOrCreateGroupOwnerPartner(db, "owner-a");
assert.equal(found.id, "partner-go-old");
assert.equal(db.partners.length, 1);
});
test("findOrCreateGroupOwnerPartner creates partner with userid fallback name", async () => {
const db = fakeProjectDb();
const created = await findOrCreateGroupOwnerPartner(db, "owner-b");
assert.match(created.id, /^partner-go-/);
assert.equal(created.name, "owner-b");
assert.equal(created.wecom_userid, "owner-b");
assert.equal(db.partners.length, 1);
});