561 lines
23 KiB
TypeScript
561 lines
23 KiB
TypeScript
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);
|
||
});
|
||
}); |