Files

561 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, afterEach } from "vitest";
import { NocoProvisioner, normalizeUsername } from "../src/integrations/nocodb/provision.js";
import type { Logger } from "../src/utils/logger.js";
const silent: Logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
trace: () => {},
fatal: () => {},
};
const mkCfg = (over: Record<string, unknown> = {}) => ({
nocodbBaseUrl: "http://nocodb:10380",
nocodbToken: "token",
nocodbBaseId: "base",
trialDaysDefault: 14,
...over,
});
/** NocoDB API surface stub: routes by URL substring, records every call. */
function stubNocoDB(opts: {
customers?: unknown[];
products?: unknown[];
grants?: unknown[];
/** Rows returned when the where clause targets telegram_id (fallback: customers). */
customersByTg?: unknown[];
/** Rows returned when the where clause targets username (fallback: customers). */
customersByUsername?: unknown[];
insertCustomers?: unknown;
insertGrants?: unknown;
}) {
const calls: { method: string; url: string; body?: unknown }[] = [];
const fetchStub = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
calls.push({ method, url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
let json: unknown;
if (url.includes("/meta/bases/base/tables")) {
json = {
list: [
{ id: "t-products", title: "Products" },
{ id: "t-customers", title: "Customers" },
{ id: "t-cp", title: "CustomerProducts" },
],
};
} else if (method === "POST" && url.includes("/tables/t-customers/records")) {
json = opts.insertCustomers ?? [{ Id: 5 }];
} else if (method === "POST" && url.includes("/tables/t-cp/records")) {
json = opts.insertGrants !== undefined ? opts.insertGrants : [{ Id: 9 }];
} else if (method === "DELETE") {
json = { ok: true };
} else if (url.includes("/tables/t-products/records")) {
json = { list: opts.products ?? [] };
} else if (url.includes("/tables/t-customers/records")) {
// Where-aware: a where clause naming a column picks that column's stub
// rows (single-condition fallbacks use `customers` as before).
const where = decodeURIComponent(url.split("where=")[1] ?? "");
if (where.includes("telegram_id") && opts.customersByTg) {
json = { list: opts.customersByTg };
} else if (where.includes("username") && opts.customersByUsername) {
// Exact-name match — respects the disambiguation loop which probes
// candidate names one at a time.
const name = (where.match(/username,eq,([a-z0-9]+)/) ?? [])[1];
json = {
list: name
? opts.customersByUsername.filter((r) => (r as { username: string }).username === name)
: [],
};
} else {
json = { list: opts.customers ?? [] };
}
} else if (url.includes("/tables/t-cp/records")) {
json = { list: opts.grants ?? [] };
} else {
throw new Error(`unhandled stub URL: ${url}`);
}
return {
ok: true,
status: 200,
json: async () => json,
} as Response;
});
vi.stubGlobal("fetch", fetchStub);
return { calls, fetchStub };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("NocoProvisioner", () => {
it("normalizeUsername: forces lowercase letters+digits only, 3-32", () => {
expect(normalizeUsername("Alice123", "1")).toEqual({ ok: true, username: "alice123" });
expect(normalizeUsername(" BoB99 ", "1")).toEqual({ ok: true, username: "bob99" });
expect(normalizeUsername("user_name", "1").ok).toBe(false);
expect(normalizeUsername("user.name", "1").ok).toBe(false);
expect(normalizeUsername("user-name", "1").ok).toBe(false);
expect(normalizeUsername("abc 中文", "1").ok).toBe(false);
expect(normalizeUsername("ab", "1").ok).toBe(false);
expect(normalizeUsername("a".repeat(33), "1").ok).toBe(false);
expect(normalizeUsername("a".repeat(32), "1")).toEqual({ ok: true, username: "a".repeat(32) });
});
it("normalizeUsername: blank falls back to tg<last8 of telegram id>, marked autoGenerated", () => {
expect(normalizeUsername(undefined, "123456789012")).toEqual({
ok: true,
username: "tg56789012",
autoGenerated: true,
});
expect(normalizeUsername(" ", "1234567890")).toEqual({
ok: true,
username: "tg34567890",
autoGenerated: true,
});
});
it("auto-generated password: 10 chars, lowercase letters + digits only", () => {
const p = new NocoProvisioner(mkCfg() as never, silent);
const gen = (p as unknown as { makePassword(): string }).makePassword;
for (let i = 0; i < 5; i++) {
const pw = gen.call(p);
expect(pw).toMatch(/^[a-z0-9]{10}$/);
}
});
it("rejects non-FREE SKUs before any network call", async () => {
const p = new NocoProvisioner(mkCfg() as never, silent);
// findProductBySku would throw on fetch failure; reaching it means the
// guard failed. We only assert the guard by calling the public method
// with a clear non-free SKU — fetch is never triggered because the
// guard returns first.
const r = await p.provisionTrial({ sku: "CDD01", telegramUserId: "1" });
expect(r.ok).toBe(false);
expect(r.message).toContain("FREE");
});
it("rejects malformed SKU tokens", async () => {
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({ sku: "not-a-sku", telegramUserId: "1" });
expect(r.ok).toBe(false);
});
it("normalises the array insert-response shape", () => {
const p = new NocoProvisioner(mkCfg() as never, silent);
const v = (p as unknown as {
firstInsertedId(r: unknown): { Id: number } | null;
}).firstInsertedId([{ Id: 42 }]);
expect(v).toEqual({ Id: 42 });
});
it("normalises the object insert-response shape", () => {
const p = new NocoProvisioner(mkCfg() as never, silent);
const v = (p as unknown as {
firstInsertedId(r: unknown): { Id: number } | null;
}).firstInsertedId({ Id: [7, 8, 9] });
expect(v).toEqual({ Id: 7 });
});
it("returns null on unrecognised insert responses", () => {
const p = new NocoProvisioner(mkCfg() as never, silent);
const v = (p as unknown as {
firstInsertedId(r: unknown): { Id: number } | null;
}).firstInsertedId({ nope: true });
expect(v).toBeNull();
});
it("falls back to config default trial days", () => {
const p = new NocoProvisioner(
mkCfg({ trialDaysDefault: 7 }) as never,
silent,
);
const v = (p as unknown as {
cfg: { trialDaysDefault: number };
}).cfg.trialDaysDefault;
expect(v).toBe(7);
});
it("口径①: same telegram_id reuses the account — only inserts CustomerProducts", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
customers: [{ Id: 5, username: "alice", status: "active", telegram_id: "111" }],
insertGrants: [{ Id: 9 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "111",
username: "alice",
});
expect(r.ok).toBe(true);
expect(r.existed).toBe(true);
expect(r.addedProduct).toBe(true);
expect(r.username).toBe("alice");
// No new Customers row; exactly one CustomerProducts insert.
const customerPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-customers/records"),
);
const grantPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-cp/records"),
);
expect(customerPosts).toHaveLength(0);
expect(grantPosts).toHaveLength(1);
const body = (grantPosts[0].body as Record<string, unknown>[])[0];
expect(body.nc_jitu___Customers_id).toBe(5);
expect(body.nc_jitu___Products_id).toBe(10);
});
it("口径①: same username (different telegram id) reuses that account", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
// telegram_id match: none → username lookup hits the existing account
customers: [{ Id: 5, username: "bob99", status: "active" }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "222",
username: "bob99",
});
expect(r.ok).toBe(true);
expect(r.existed).toBe(true);
expect(r.addedProduct).toBe(true);
expect(r.username).toBe("bob99");
const customerPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-customers/records"),
);
expect(customerPosts).toHaveLength(0);
});
it("口径①: already granted the product — no duplicate CustomerProducts insert", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
customers: [{ Id: 5, username: "alice", status: "active", telegram_id: "111" }],
grants: [{ Id: 9, nc_jitu___Customers_id: 5, nc_jitu___Products_id: 10 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "111",
username: "alice",
});
expect(r.ok).toBe(true);
expect(r.existed).toBe(true);
expect(r.addedProduct).toBeUndefined(); // no new grant
const grantPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-cp/records"),
);
expect(grantPosts).toHaveLength(0);
});
it("new customer: inserts Customers + CustomerProducts and returns password", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
customers: [], // no telegram match, no username match
insertCustomers: [{ Id: 5 }],
insertGrants: [{ Id: 9 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "333",
username: "carol1",
});
expect(r.ok).toBe(true);
expect(r.existed).toBeUndefined();
expect(r.username).toBe("carol1");
expect(r.password).toMatch(/^[a-z0-9]{10}$/);
const customerPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-customers/records"),
);
const grantPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-cp/records"),
);
expect(customerPosts).toHaveLength(1);
expect(grantPosts).toHaveLength(1);
});
it("grant insert failure rolls back the new customer row", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
customers: [],
insertCustomers: [{ Id: 5 }],
insertGrants: null as never, // POST /t-cp returns `null` → firstInsertedId null
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "444",
username: "dave22",
});
expect(r.ok).toBe(false);
const deletes = calls.filter((c) => c.method === "DELETE");
expect(deletes).toHaveLength(1);
expect(deletes[0].body).toEqual([{ Id: 5 }]);
});
it("regression: multi-condition where uses NocoDB v2 `~and` connector (`,AND,` silently returns empty)", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
customers: [{ Id: 5, username: "alice", status: "active", telegram_id: "111" }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "111",
username: "alice",
});
expect(r.ok).toBe(true);
expect(r.existed).toBe(true);
// EVERY multi-condition where query on Customers/CustomerProducts must use
// the v2 connector `~and` — `,AND,` parses to empty results on NocoDB
// v2 (verified live 2026-08-31), which made reuse silently fail and the
// "username is taken" branch fire instead of granting to the existing
// account (the exact bug reported by the customer).
const whereQueries = calls
.filter((c) => c.method === "GET" && c.url.includes("where="))
.map((c) => decodeURIComponent(c.url.split("where=")[1]));
expect(whereQueries.length).toBeGreaterThan(0);
// Only queries that actually combine conditions with a connector are in
// scope — single-condition lookups like (sku,eq,...) are fine either way.
const multi = whereQueries.filter((w) => /,AND,|~and~|~and\(|~or\(/.test(w));
expect(multi.length).toBeGreaterThan(0);
for (const w of multi) {
expect(w).not.toMatch(/,AND,/);
expect(w).not.toMatch(/~and~/);
expect(w).toMatch(/~and\(/);
}
});
it("autoGenerated tg fallback username is never used for account reuse", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
// No account linked to this telegram id — the only collision is with the
// auto-generated fallback username "tg34567890" on someone else's row.
customersByTg: [],
customersByUsername: [{ Id: 7, username: "tg34567890", status: "active" }],
insertCustomers: [{ Id: 5 }],
insertGrants: [{ Id: 9 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "1234567890", // fallback → tg34567890 collides with Id 7
// username omitted → normalizeUsername auto-generates
});
expect(r.ok).toBe(true);
expect(r.existed).toBeUndefined(); // must NOT reuse Id 7
// Collision on the raw fallback → disambiguation appends a counter.
expect(r.username).toBe("tg345678901");
// A new customer row was created instead of a grant to the colliding one.
const customerPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-customers/records"),
);
expect(customerPosts).toHaveLength(1);
});
it("第2批: trial SKU 详情链接指向原付费品页(FREECXM04 → /products/cxm04/", async () => {
const p = new NocoProvisioner(mkCfg() as never, silent);
const url = (p as unknown as { productUrl(sku: string): string }).productUrl;
// FREE 前缀去掉 → 命中本地 catalog 原品页
expect(url.call(p, "FREECXM04")).toBe("https://www.digikedai.com/products/cxm04/");
expect(url.call(p, "FREECXM10")).toBe("https://www.digikedai.com/products/cxm10/");
expect(url.call(p, "FREECDD01")).toBe("https://www.digikedai.com/products/cdd01/");
expect(url.call(p, "FREECXM04")).not.toContain("/products/freecxm04/");
// 任意 FREE 试看 → 原品 URL(不依赖具体 SKU,只要 catalog 有 twin
expect(url.call(p, "FREECMY01")).toBe("https://www.digikedai.com/products/cmy01/");
// 原品页不存在(理论缺口)→ 回退产品列表页
expect(url.call(p, "FREEXYZZ9")).toBe("https://www.digikedai.com/products");
// 非 FREE SKU → 原逻辑不变
expect(url.call(p, "CXM04")).toBe("https://www.digikedai.com/products/cxm04/");
});
it("四·补·三第3批: probeExisting 命中 telegram_id 既有账号(只读不写库)", async () => {
const { calls } = stubNocoDB({
customersByTg: [{ Id: 13, username: "lover", status: "active", telegram_id: "6328024625" }],
customersByUsername: [],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const hit = await p.probeExisting({
telegramUserId: "6328024625",
username: "lover",
});
expect(hit).toEqual([{ id: 13, username: "lover" }]);
// 只读探测:没有任何 POST
expect(calls.some((c) => c.method === "POST")).toBe(false);
});
it("四·补·三第3批: probeExisting 未命中 telegram_id 时按显式 username 探测", async () => {
stubNocoDB({
customersByTg: [],
customersByUsername: [{ Id: 13, username: "lover", status: "active" }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const hit = await p.probeExisting({
telegramUserId: "9999999999",
username: "Lover", // 规范化后 lover
});
expect(hit).toEqual([{ id: 13, username: "lover" }]);
});
it("四·补·三第3批: probeExisting 自动生成的 tg 假名绝不参与复用", async () => {
stubNocoDB({
customersByTg: [],
customersByUsername: [{ Id: 13, username: "tg34567890", status: "active" }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
// 无显式 username → normalizeUsername 生成 tg 假名 → 不按 username 探测 → null
const hit = await p.probeExisting({ telegramUserId: "1234567890" });
expect(hit).toEqual([]);
});
it("四·补·三第3批: probeExisting 两者都未命中返回空列表", async () => {
stubNocoDB({ customersByTg: [], customersByUsername: [] });
const p = new NocoProvisioner(mkCfg() as never, silent);
expect(
await p.probeExisting({ telegramUserId: "111", username: "nobody" }),
).toEqual([]);
});
it("四·补·三第3批: forceNew 跳过既有账号复用,直接新建(仍有 username", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
// tg 命中 lover —— 但用户明确要新账号,不得复用
customersByTg: [{ Id: 13, username: "lover", status: "active", telegram_id: "6328024625" }],
insertCustomers: [{ Id: 5 }],
insertGrants: [{ Id: 9 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "6328024625",
username: "newbie1",
forceNew: true,
});
expect(r.ok).toBe(true);
expect(r.existed).toBeUndefined();
expect(r.username).toBe("newbie1");
// 新建了 Customers 行 + CP 行
const customerPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-customers/records"),
);
const grantPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-cp/records"),
);
expect(customerPosts).toHaveLength(1);
expect(grantPosts).toHaveLength(1);
expect((customerPosts[0].body as Record<string, unknown>[])[0].username).toBe("newbie1");
});
it("四·补·三第3批: forceNew + 无 username → 自动假名新建(不对既有 lover 复用)", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM04", is_trial: true, trial_days: 14 }],
customersByTg: [{ Id: 13, username: "lover", status: "active", telegram_id: "6328024625" }],
insertCustomers: [{ Id: 5 }],
insertGrants: [{ Id: 9 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM04",
telegramUserId: "6328024625",
forceNew: true, // 无 username
});
expect(r.ok).toBe(true);
// 自动假名(forceNew 分支不 recall 记忆——bot 层保证,这里只验证不撞 lover)
expect(r.username).toMatch(/^tg/);
expect(r.username).not.toBe("lover");
expect(calls.some((c) => c.method === "POST" && c.url.includes("/tables/t-customers/records"))).toBe(true);
});
it("四·补·三第3批: 确认复用路径(confirmReuse 语义)——直接加到既有账号", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM10", is_trial: true, trial_days: 14 }],
customersByTg: [{ Id: 13, username: "lover", status: "active", telegram_id: "6328024625" }],
insertGrants: [{ Id: 9 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
// 用户确认后,bot 用记忆/探测到的 username 调 provisionTrial(非 forceNew
const r = await p.provisionTrial({
sku: "FREECXM10",
telegramUserId: "6328024625",
username: "lover",
});
expect(r.ok).toBe(true);
expect(r.existed).toBe(true);
expect(r.addedProduct).toBe(true);
expect(r.username).toBe("lover");
// 不新建 Customers
expect(
calls.filter((c) => c.method === "POST" && c.url.includes("/tables/t-customers/records")),
).toHaveLength(0);
});
it("四·补·五第1批: probeExisting 同 telegram_id 返回全部账号(多账号)", async () => {
const { calls } = stubNocoDB({
customersByTg: [
{ Id: 13, username: "lover", status: "active", telegram_id: "6328024625" },
{ Id: 14, username: "abc", status: "active", telegram_id: "6328024625" },
],
customersByUsername: [],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const hit = await p.probeExisting({
telegramUserId: "6328024625",
username: "lover",
});
expect(hit).toEqual([
{ id: 13, username: "lover" },
{ id: 14, username: "abc" },
]);
// 只读探测:没有任何 POST
expect(calls.some((c) => c.method === "POST")).toBe(false);
});
it("四·补·五第1批: provisionTrial + customerId 加到指定账号(不是 [0]", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM10", is_trial: true, trial_days: 14 }],
// findCustomerById(14) 的 where 不含 telegram_id/username → 命中 customers
customers: [{ Id: 14, username: "abc", status: "active" }],
insertGrants: [{ Id: 9 }],
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM10",
telegramUserId: "6328024625",
customerId: 14,
});
expect(r.ok).toBe(true);
expect(r.existed).toBe(true);
expect(r.addedProduct).toBe(true);
expect(r.username).toBe("abc");
const customerPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-customers/records"),
);
const grantPosts = calls.filter(
(c) => c.method === "POST" && c.url.includes("/tables/t-cp/records"),
);
expect(customerPosts).toHaveLength(0);
expect(grantPosts).toHaveLength(1);
const body = (grantPosts[0].body as Record<string, unknown>[])[0];
expect(body.nc_jitu___Customers_id).toBe(14);
expect(body.nc_jitu___Products_id).toBe(10);
});
it("四·补·五第1批: provisionTrial + customerId 查无账号报错", async () => {
const { calls } = stubNocoDB({
products: [{ Id: 10, sku: "FREECXM10", is_trial: true, trial_days: 14 }],
customers: [], // findCustomerById(999) → 空
});
const p = new NocoProvisioner(mkCfg() as never, silent);
const r = await p.provisionTrial({
sku: "FREECXM10",
telegramUserId: "6328024625",
customerId: 999,
});
expect(r.ok).toBe(false);
expect(r.message).toContain("Account not found");
expect(calls.some((c) => c.method === "POST")).toBe(false);
});
});