fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config. Co-authored-by: Cursor <cursoragent@cursor.com>
298 lines
10 KiB
JavaScript
298 lines
10 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
buildTemplatePreviewHtml,
|
|
createPageTemplateCatalogService,
|
|
mapTemplateCatalogRow,
|
|
seedTemplateCatalogFromSkills,
|
|
} from './page-template-catalog.mjs';
|
|
import { isPageTemplateSkill } from './skills-registry.mjs';
|
|
|
|
test('isPageTemplateSkill detects page-template prefix', () => {
|
|
assert.equal(isPageTemplateSkill('page-template-travel'), true);
|
|
assert.equal(isPageTemplateSkill('page-template-campaign'), true);
|
|
assert.equal(isPageTemplateSkill('static-page-publish'), false);
|
|
});
|
|
|
|
test('mapTemplateCatalogRow maps db row', () => {
|
|
const item = mapTemplateCatalogRow({
|
|
skill_name: 'page-template-travel',
|
|
label: '旅游 Hero 模板',
|
|
description: 'desc',
|
|
preview_url: '/preview.png',
|
|
price_cents: 990,
|
|
currency: 'CNY',
|
|
billing_mode: 'one_time',
|
|
status: 'active',
|
|
sort_order: 10,
|
|
usage_count: 12,
|
|
favorite_count: 3,
|
|
});
|
|
assert.equal(item.skillName, 'page-template-travel');
|
|
assert.equal(item.priceCents, 990);
|
|
assert.equal(item.chatSkillId, 'page-template-travel');
|
|
assert.equal(item.usageCount, 12);
|
|
assert.equal(item.favoriteCount, 3);
|
|
});
|
|
|
|
function createMockPool() {
|
|
const state = {
|
|
catalog: new Map(),
|
|
purchases: new Map(),
|
|
wallets: new Map(),
|
|
ledger: [],
|
|
};
|
|
|
|
const pool = {
|
|
state,
|
|
async query(sql, params = []) {
|
|
const text = String(sql);
|
|
if (text.includes('CREATE TABLE IF NOT EXISTS h5_template_catalog')) return [[]];
|
|
if (text.includes('CREATE TABLE IF NOT EXISTS h5_template_purchases')) return [[]];
|
|
if (text.includes('CREATE TABLE IF NOT EXISTS h5_template_favorites')) return [[]];
|
|
if (text.includes('CREATE TABLE IF NOT EXISTS h5_template_user_usage')) return [[]];
|
|
if (text.includes('ALTER TABLE h5_template_catalog ADD COLUMN usage_count')) return [[]];
|
|
if (text.includes('FROM h5_template_favorites')) return [[], []];
|
|
if (text.includes('FROM h5_template_user_usage')) return [[], []];
|
|
if (text.includes('INSERT INTO h5_template_favorites')) return [{ affectedRows: 1 }, []];
|
|
if (text.includes('DELETE FROM h5_template_favorites')) return [{ affectedRows: 1 }, []];
|
|
if (text.includes('usage_count = usage_count + 1')) return [{ affectedRows: 1 }, []];
|
|
if (text.includes('favorite_count = favorite_count + 1') || text.includes('favorite_count = GREATEST')) {
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (text.includes('INSERT INTO h5_template_user_usage')) return [{ affectedRows: 1 }, []];
|
|
if (text.includes('INSERT INTO h5_template_catalog') && text.includes('ON DUPLICATE KEY UPDATE')) {
|
|
const skillName = params[0];
|
|
state.catalog.set(skillName, {
|
|
skill_name: skillName,
|
|
label: params[1],
|
|
description: params[2],
|
|
preview_url: params[3],
|
|
price_cents: params[4],
|
|
currency: 'CNY',
|
|
billing_mode: params[5],
|
|
status: 'active',
|
|
sort_order: params[6],
|
|
});
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (text.includes('FROM h5_template_catalog') && text.includes("status = 'active'")) {
|
|
return [[...state.catalog.values()], []];
|
|
}
|
|
if (text.includes('FROM h5_template_catalog WHERE skill_name = ?')) {
|
|
const row = state.catalog.get(params[0]);
|
|
return [row ? [row] : [], []];
|
|
}
|
|
if (text.includes('FROM h5_template_purchases') && text.includes('skill_name')) {
|
|
const owned = [...state.purchases.values()].filter(
|
|
(row) => row.user_id === params[0] && row.skill_name === params[1],
|
|
);
|
|
return [owned, []];
|
|
}
|
|
if (text.includes('FROM h5_template_purchases') && text.includes('WHERE user_id = ?')) {
|
|
const rows = [...state.purchases.values()].filter((row) => row.user_id === params[0]);
|
|
return [rows, []];
|
|
}
|
|
if (text.includes('INSERT INTO h5_template_purchases')) {
|
|
const key = `${params[1]}:${params[2]}`;
|
|
state.purchases.set(key, {
|
|
id: params[0],
|
|
user_id: params[1],
|
|
skill_name: params[2],
|
|
order_id: params[3],
|
|
source: params[4],
|
|
purchased_at: params[5],
|
|
expires_at: params[6],
|
|
});
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (text.includes('SELECT balance_cents FROM h5_user_wallets')) {
|
|
return [[{ balance_cents: state.wallets.get(params[0]) ?? 0 }], []];
|
|
}
|
|
if (text.includes('UPDATE h5_user_wallets')) {
|
|
const delta = Number(params[0]);
|
|
const userId = params[2];
|
|
state.wallets.set(userId, (state.wallets.get(userId) ?? 0) - delta);
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (text.includes('INSERT INTO h5_billing_ledger')) {
|
|
state.ledger.push(params);
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (text.includes('INSERT INTO h5_payment_orders')) {
|
|
state.paymentOrders = state.paymentOrders ?? new Map();
|
|
state.paymentOrders.set(params[0], {
|
|
id: params[0],
|
|
user_id: params[1],
|
|
amount_cents: params[2],
|
|
order_purpose_ref: params[11],
|
|
});
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
throw new Error(`Unhandled SQL in mock pool: ${text.slice(0, 120)}`);
|
|
},
|
|
async getConnection() {
|
|
const conn = {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
async release() {},
|
|
query: (...args) => pool.query(...args),
|
|
};
|
|
return conn;
|
|
},
|
|
};
|
|
return pool;
|
|
}
|
|
|
|
test('purchaseWithBalance grants skill and deducts wallet', async () => {
|
|
const pool = createMockPool();
|
|
pool.state.catalog.set('page-template-travel', {
|
|
skill_name: 'page-template-travel',
|
|
label: '旅游 Hero 模板',
|
|
description: '',
|
|
preview_url: null,
|
|
price_cents: 990,
|
|
currency: 'CNY',
|
|
billing_mode: 'one_time',
|
|
status: 'active',
|
|
sort_order: 10,
|
|
});
|
|
pool.state.wallets.set('user-1', 5000);
|
|
const grants = {};
|
|
const userAuth = {
|
|
async getUserSkills(userId) {
|
|
return {
|
|
ok: true,
|
|
grantedSkills: Object.entries(grants[userId] ?? {})
|
|
.filter(([, enabled]) => enabled)
|
|
.map(([name]) => name),
|
|
};
|
|
},
|
|
async setUserSkills(userId, patch) {
|
|
grants[userId] = { ...(grants[userId] ?? {}), ...patch };
|
|
return {
|
|
ok: true,
|
|
grantedSkills: Object.entries(grants[userId])
|
|
.filter(([, enabled]) => enabled)
|
|
.map(([name]) => name),
|
|
};
|
|
},
|
|
async getUserById(userId) {
|
|
return { id: userId, balance_cents: pool.state.wallets.get(userId) ?? 0 };
|
|
},
|
|
};
|
|
const service = createPageTemplateCatalogService(pool, { userAuth, h5Root: process.cwd() });
|
|
const result = await service.purchaseWithBalance('user-1', 'page-template-travel');
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.priceCents, 990);
|
|
assert.equal(pool.state.wallets.get('user-1'), 4010);
|
|
assert.deepEqual(result.grantedSkills, ['page-template-travel']);
|
|
});
|
|
|
|
test('buildTemplatePreviewHtml renders demo travel template', () => {
|
|
const result = buildTemplatePreviewHtml('page-template-travel', process.cwd());
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.source, 'demo-fill');
|
|
assert.match(result.html, /仙居玩水完全指南/);
|
|
assert.doesNotMatch(result.html, /\{\{PAGE_TITLE\}\}/);
|
|
});
|
|
|
|
test('createWechatCheckout creates pending payment order', async () => {
|
|
const pool = createMockPool();
|
|
pool.state.catalog.set('page-template-travel', {
|
|
skill_name: 'page-template-travel',
|
|
label: '旅游 Hero 模板',
|
|
description: '',
|
|
preview_url: null,
|
|
price_cents: 990,
|
|
currency: 'CNY',
|
|
billing_mode: 'one_time',
|
|
status: 'active',
|
|
sort_order: 10,
|
|
});
|
|
const userAuth = {
|
|
async getUserById(userId) {
|
|
return { id: userId, status: 'active', balance_cents: 0 };
|
|
},
|
|
async getWechatOpenidForUser() {
|
|
return 'openid-1';
|
|
},
|
|
async setUserSkills() {
|
|
return { ok: true, grantedSkills: [] };
|
|
},
|
|
};
|
|
const wechatPay = {
|
|
enabled: true,
|
|
appId: 'wx-test',
|
|
async createNativeOrder() {
|
|
return { codeUrl: 'weixin://wxpay/bizpayurl?pr=test' };
|
|
},
|
|
};
|
|
const service = createPageTemplateCatalogService(pool, { userAuth, wechatPay, h5Root: process.cwd() });
|
|
const result = await service.createWechatCheckout({
|
|
userId: 'user-1',
|
|
skillName: 'page-template-travel',
|
|
payScene: 'native',
|
|
clientIp: '127.0.0.1',
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.order.amountCents, 990);
|
|
assert.equal(result.order.payMode, 'native');
|
|
assert.ok(result.order.codeUrl);
|
|
});
|
|
|
|
test('fulfillWechatPurchaseLocked grants template skill', async () => {
|
|
const pool = createMockPool();
|
|
pool.state.catalog.set('page-template-travel', {
|
|
skill_name: 'page-template-travel',
|
|
label: '旅游 Hero 模板',
|
|
description: '',
|
|
preview_url: null,
|
|
price_cents: 990,
|
|
currency: 'CNY',
|
|
billing_mode: 'one_time',
|
|
status: 'active',
|
|
sort_order: 10,
|
|
});
|
|
const grants = {};
|
|
const userAuth = {
|
|
async setUserSkills(userId, patch) {
|
|
grants[userId] = { ...(grants[userId] ?? {}), ...patch };
|
|
return {
|
|
ok: true,
|
|
grantedSkills: Object.entries(grants[userId])
|
|
.filter(([, enabled]) => enabled)
|
|
.map(([name]) => name),
|
|
};
|
|
},
|
|
async getUserById(userId) {
|
|
return { id: userId, balance_cents: 1000 };
|
|
},
|
|
};
|
|
const service = createPageTemplateCatalogService(pool, { userAuth, h5Root: process.cwd() });
|
|
const conn = await pool.getConnection();
|
|
const result = await service.fulfillWechatPurchaseLocked({
|
|
conn,
|
|
order: {
|
|
id: 'order-1',
|
|
userId: 'user-1',
|
|
amountCents: 990,
|
|
orderPurposeRef: 'page-template-travel',
|
|
},
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.grantedSkills, ['page-template-travel']);
|
|
});
|
|
|
|
test('seedTemplateCatalogFromSkills inserts platform template skills', async () => {
|
|
const pool = createMockPool();
|
|
await seedTemplateCatalogFromSkills(pool, process.cwd());
|
|
assert.ok(pool.state.catalog.has('page-template-travel'));
|
|
assert.ok(pool.state.catalog.has('page-template-campaign'));
|
|
assert.ok(pool.state.catalog.has('page-template-restaurant'));
|
|
assert.ok(pool.state.catalog.has('page-template-product-grid'));
|
|
assert.equal(pool.state.catalog.size, 20);
|
|
assert.equal(pool.state.catalog.get('page-template-travel').price_cents, 990);
|
|
});
|