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); });