374 lines
14 KiB
TypeScript
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)));
|
|
});
|