Files
nocobase-mcp/test/crm-service.test.ts

374 lines
14 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_CONFIG } from "../src/config.js";
import { CrmService } from "../src/crm-service.js";
import type { NocoBaseClient } from "../src/nocobase-client.js";
test("renders compact customer rows, labels, relations, timezone, and pagination", async () => {
const requests: Array<Record<string, unknown>> = [];
const fake = {
listRecords: async (resource: string, options: Record<string, unknown>) => {
requests.push({ resource, ...options });
return {
data: [
{
id: 42,
f_q2q60q7bqbg: "Acme",
kh_progress: "kh_p_inprogress",
f_225h6n6v654: "Referral",
kehu_followup_person: [{ id: 7, nickname: "Alice" }],
updatedAt: "2026-07-08T09:00:00Z",
},
],
meta: { count: 1 },
};
},
} as unknown as NocoBaseClient;
const config = structuredClone(DEFAULT_CONFIG);
config.customer.resource = "crm_customers";
config.customer.fields = [
"id",
"f_q2q60q7bqbg",
"kh_progress",
"f_225h6n6v654",
"kehu_followup_person",
"updatedAt",
];
config.customer.statusField = "kh_progress";
config.customer.sourceField = "f_225h6n6v654";
config.customer.ownerField = "kehu_followup_person";
const result = await new CrmService(config, fake).listCustomers({ limit: 20 });
assert.deepEqual(result.results, [
{
id: 42,
name: "Acme",
status: "进行中",
source: "Referral",
owners: [{ id: 7, name: "Alice" }],
updatedAt: "2026-07-08T17:00:00+08:00",
},
]);
assert.equal(result.total, 1);
assert.equal(result.timezone, "Asia/Shanghai");
assert.equal(requests[0]?.resource, "crm_customers");
});
test("implements exact arbitrary offsets across two NocoBase pages", async () => {
const pages: number[] = [];
const fake = {
listRecords: async (_resource: string, options: Record<string, unknown>) => {
const page = options.page as number;
pages.push(page);
const start = (page - 1) * 20;
return {
data: Array.from({ length: 20 }, (_, index) => ({
id: start + index,
name: `Customer ${start + index}`,
})),
meta: { count: 100 },
};
},
} as unknown as NocoBaseClient;
const result = await new CrmService(structuredClone(DEFAULT_CONFIG), fake).listCustomers({
limit: 20,
offset: 25,
});
assert.deepEqual(pages, [2, 3]);
assert.deepEqual(
(result.results as Array<Record<string, unknown>>).map((row) => row.id),
Array.from({ length: 20 }, (_, i) => 25 + i),
);
assert.equal(result.next_offset, 45);
});
test("resolves owner candidates and returns ambiguity as structured data", async () => {
const fake = {
listRecords: async (resource: string) => {
if (resource === "users") {
return {
data: [
{ id: 1, username: "alex", nickname: "Alex A" },
{ id: 2, username: "alex", nickname: "Alex B" },
],
};
}
return { data: [], meta: { count: 0 } };
},
} as unknown as NocoBaseClient;
const service = new CrmService(structuredClone(DEFAULT_CONFIG), fake);
const resolved = await service.resolveOwner("alex");
assert.equal(resolved.count, 2);
const listed = await service.listCustomers({ owner: "alex" });
assert.equal((listed.error as Record<string, unknown>).code, "owner_ambiguous");
});
test("owner-filtered CRM overview counts only contacts linked to that owner's customers", async () => {
const config = structuredClone(DEFAULT_CONFIG);
const queries: Array<{ resource: string; options: Record<string, unknown> }> = [];
const fake = {
listRecords: async (resource: string) => {
if (resource === config.user.resource) {
return { data: [{ id: 10, username: "alice", nickname: "Alice" }] };
}
return { data: [], meta: { count: 0 } };
},
queryRecords: async (resource: string, options: Record<string, unknown>) => {
queries.push({ resource, options });
return Array.isArray(options.dimensions) ? { data: [] } : { data: [{ count: 1 }] };
},
} as unknown as NocoBaseClient;
const result = await new CrmService(config, fake).crmOverview({ owner: "alice" });
assert.deepEqual(result.totals, { customers: 1, contacts: 1, followups: 1 });
const contactCount = queries.find(
({ resource, options }) => resource === config.contact.resource && Array.isArray(options.measures),
);
assert.deepEqual(contactCount?.options.filter, {
$and: [
{
[config.contact.customerField]: {
[config.customer.ownerField]: { id: { $eq: 10 } },
},
},
],
});
});
test("health never returns the Authorize Key", async () => {
const fake = {
health: async () => ({ ok: true, endpoint: "https://nb.example", status: "authenticated" }),
} as unknown as NocoBaseClient;
const result = await new CrmService(structuredClone(DEFAULT_CONFIG), fake).crmHealth();
assert.equal(result.ok, true);
assert.doesNotMatch(JSON.stringify(result), /token|secret|authorizeKey/i);
});
test("getProject returns contacts only at the top level when requested", async () => {
const config = structuredClone(DEFAULT_CONFIG);
const requests: Array<Record<string, unknown>> = [];
const fake = {
getRecord: async (_resource: string, _projectId: unknown, options: Record<string, unknown>) => {
requests.push(options);
return {
data: {
[config.project.idField]: 30,
[config.project.nameField]: "Project",
[config.project.contactField]: [{ [config.contact.idField]: 2, name: "Contact" }],
},
};
},
} as unknown as NocoBaseClient;
const service = new CrmService(config, fake);
const withoutContacts = await service.getProject(30, {
includeContacts: false,
includeFollowups: false,
includeUpdates: false,
});
assert.equal("contacts" in withoutContacts, false);
assert.equal("contacts" in (withoutContacts.project as Record<string, unknown>), false);
assert.equal(
((requests[0]?.fields ?? []) as string[]).some(
(field) => field === config.project.contactField || field.startsWith(`${config.project.contactField}.`),
),
false,
);
const withContacts = await service.getProject(30, {
includeContacts: true,
includeFollowups: false,
includeUpdates: false,
});
assert.deepEqual(withContacts.contacts, [{ id: 2, name: "Contact" }]);
assert.equal("contacts" in (withContacts.project as Record<string, unknown>), false);
assert.equal(
((requests[1]?.fields ?? []) as string[]).some((field) =>
field.startsWith(`${config.project.contactField}.`),
),
true,
);
});
test("listStaleCustomers uses paginated max aggregation and returns an exact total", async () => {
const config = structuredClone(DEFAULT_CONFIG);
const customers = Array.from({ length: 101 }, (_, index) => ({
[config.customer.idField]: index + 1,
name: `Customer ${index + 1}`,
}));
const latest = customers.map((customer) => ({
customer_id: customer[config.customer.idField],
last_followup_at: "2026-01-01T00:00:00Z",
}));
const queryRequests: Array<Record<string, unknown>> = [];
const fake = {
listRecords: async (resource: string, options: Record<string, unknown>) => {
assert.equal(resource, config.customer.resource);
const pageSize = options.pageSize as number;
const start = ((options.page as number) - 1) * pageSize;
return { data: customers.slice(start, start + pageSize), meta: { count: customers.length } };
},
queryRecords: async (resource: string, options: Record<string, unknown>) => {
assert.equal(resource, config.followup.resource);
queryRequests.push(options);
const offset = options.offset as number;
const limit = options.limit as number;
return { data: latest.slice(offset, offset + limit) };
},
} as unknown as NocoBaseClient;
const result = await new CrmService(config, fake).listStaleCustomers({
asOf: "2026-08-20",
inactiveDays: 30,
limit: 20,
});
assert.equal(result.total, 101);
assert.equal(result.total_is_exact, true);
assert.deepEqual(
queryRequests.map((request) => request.offset),
[0, 100],
);
assert.deepEqual(queryRequests[0]?.dimensions, [
{
field: [config.followup.customerField, config.customer.idField],
alias: "customer_id",
},
]);
assert.deepEqual(queryRequests[0]?.measures, [
{ field: [config.followup.dateField], aggregation: "max", alias: "last_followup_at" },
]);
assert.equal(queryRequests[0]?.timezone, config.timezone);
});
test("listStaleCustomers never presents a truncated scan count as an exact total", async () => {
const config = structuredClone(DEFAULT_CONFIG);
const fake = {
listRecords: async () => ({
data: [{ [config.customer.idField]: 1, name: "Customer" }],
meta: { count: 2 },
}),
queryRecords: async () => ({ data: [] }),
} as unknown as NocoBaseClient;
const result = await new CrmService(config, fake).listStaleCustomers({
asOf: "2026-08-20",
inactiveDays: 30,
});
assert.equal("total" in result, false);
assert.equal(result.total_is_exact, false);
assert.equal(result.scanned_stale_count, 1);
assert.equal(result.pagination_scope, "scanned_records_only");
assert.deepEqual(result.data_quality_warnings, ["customer scan was truncated"]);
});
test("listStaleCustomers marks an overflowing follow-up aggregation as inexact", async () => {
const config = structuredClone(DEFAULT_CONFIG);
const latest = Array.from({ length: 5_001 }, (_, index) => ({
customer_id: index + 1,
last_followup_at: "2026-01-01T00:00:00Z",
}));
const fake = {
listRecords: async () => ({
data: [{ [config.customer.idField]: 1, name: "Customer" }],
meta: { count: 1 },
}),
queryRecords: async (_resource: string, options: Record<string, unknown>) => {
const offset = options.offset as number;
const limit = options.limit as number;
return { data: latest.slice(offset, offset + limit) };
},
} as unknown as NocoBaseClient;
const result = await new CrmService(config, fake).listStaleCustomers({
asOf: "2026-08-20",
inactiveDays: 30,
});
assert.equal("total" in result, false);
assert.equal(result.total_is_exact, false);
assert.equal(result.scanned_stale_count, 1);
assert.deepEqual(result.data_quality_warnings, ["follow-up customer aggregation was truncated"]);
assert.deepEqual(result.truncation, {
followups: { minimum_total: 5_001, returned: 5_000, limit: 5_000, next_offset: 5_000 },
});
});
test("all CRM service surfaces execute against the direct HTTP adapter contract", async () => {
const config = structuredClone(DEFAULT_CONFIG);
const rows: Record<string, Record<string, unknown>> = {
[config.customer.resource]: {
id: 1,
name: "Acme",
owner: [{ id: 10, nickname: "Alice" }],
updatedAt: "2026-01-01T00:00:00Z",
},
[config.contact.resource]: { id: 2, name: "Bob", customer: [{ id: 1, name: "Acme" }] },
[config.followup.resource]: {
id: 3,
followedAt: "2026-01-02T00:00:00Z",
customer: [{ id: 1, name: "Acme" }],
owner: [{ id: 10, nickname: "Alice" }],
content: "Followed up",
},
[config.user.resource]: {
id: 10,
username: "alice",
nickname: "Alice",
[config.team.userStatusField]: config.team.formalUserStatusValue,
},
[config.team.resource]: {
id: 20,
[config.team.nameField]: "Alpha",
[config.team.managerField]: [{ id: 10 }],
[config.team.memberField]: [{ id: 10 }],
},
[config.project.resource]: {
id: 30,
[config.project.nameField]: "Project",
[config.project.progressField]: config.project.progressValues.已报价,
[config.project.customerRelationField]: [{ id: 1, name: "Acme" }],
},
[config.project.updateResource]: {
id: 31,
[config.project.updateDateField]: "2026-01-03T00:00:00Z",
[config.project.updateContentField]: "Updated",
createdBy: [{ id: 10, nickname: "Alice" }],
},
};
const fake = {
health: async () => ({ ok: true, endpoint: "https://nb.example", status: "authenticated" }),
listCollectionFields: async () => ({ data: [] }),
getRecord: async (resource: string) => ({ data: rows[resource] }),
listRecords: async (resource: string) => ({
data: rows[resource] ? [rows[resource]] : [],
meta: { count: rows[resource] ? 1 : 0 },
}),
queryRecords: async (_resource: string, options: Record<string, unknown>) => {
const dimensions = options.dimensions as Array<Record<string, unknown>> | undefined;
const alias = dimensions?.[0]?.alias;
return { data: [{ ...(typeof alias === "string" ? { [alias]: "value" } : {}), count: 1 }] };
},
} as unknown as NocoBaseClient;
const service = new CrmService(config, fake);
const results = await Promise.all([
service.crmHealth(),
service.crmSchema(true),
service.crmConfigTemplate(),
service.listCustomers(),
service.getCustomer(1),
service.listContacts(),
service.listFollowups(),
service.listProjects(),
service.getProject(30),
service.resolveOwner("alice"),
service.listTeams({ includeMembers: true }),
service.resolveTeam("Alpha"),
service.getTeam(20),
service.crmOverview(),
service.crmFilterOptions("alice"),
service.teamCrmOverview("Alpha"),
service.getProjectTimeline(30),
service.listStaleCustomers({ asOf: "2026-08-15", inactiveDays: 30 }),
]);
assert.equal(results.length, 18);
assert.ok(results.every((result) => !("error" in result)));
});