skeleton.mjs 6.73 KB
#!/usr/bin/env node
/**
 * 初始化清单「验收」的真实链路脚本:注册 → 申请 → 运营审核 → 积分记账与幂等。
 *
 * 前提:voko-server 已启动(VOKO_API,默认 http://localhost:9201),.env 已配置真实 SMTP;
 *   VOKO_TEST_EMAIL:一个能收信的邮箱,脚本用 plus 地址注册新用户(如 you+voko-xxx@gmail.com);
 *   VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD:一个已用 admin:grant 设为管理员的账号。
 * 缺任一项输出 BLOCKED 并以 2 退出,不用替身补绿。
 * 结束时只清理本脚本创建的数据。
 */
import { randomUUID } from 'node:crypto'
import { createBillingRepo, createLedger } from '@drama/billing'
import { createDb } from '@drama/db'

import { databaseUrlFromArgs } from '../../../src/db/env-file.js'

const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '')
const missing = ['VOKO_TEST_EMAIL', 'VOKO_ADMIN_EMAIL', 'VOKO_ADMIN_PASSWORD'].filter((name) => !process.env[name])
if (missing.length > 0) {
  console.log(JSON.stringify({ status: 'BLOCKED', missing }))
  process.exit(2)
}

const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 2 } })
const [local, domain] = String(process.env.VOKO_TEST_EMAIL).split('@')
const email = `${local}+voko-skeleton-${Date.now()}@${domain}`.toLowerCase()
const results = []
let userId = null

/**
 * @param {string} name
 * @param {boolean} pass
 * @param {unknown} [detail]
 */
function check(name, pass, detail) {
  results.push({ name, pass, ...(pass ? {} : { detail }) })
  if (!pass) throw new Error(`FAILED: ${name} ${JSON.stringify(detail)}`)
}

/**
 * @param {string} method
 * @param {string} path
 * @param {{ body?: unknown, token?: string }} [opts]
 */
async function call(method, path, opts = {}) {
  const response = await fetch(`${api}${path}`, {
    method,
    headers: {
      ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }),
      ...(opts.token === undefined ? {} : { authorization: `Bearer ${opts.token}` }),
    },
    body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
  })
  return { status: response.status, json: await response.json() }
}

try {
  const health = await call('GET', '/healthz')
  check('healthz', health.status === 200 && health.json.ok === true, health)

  const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } })
  check('send-code', sent.status === 200, sent)
  // auth.sendCode 不等待 SMTP 就返回,这里等真实投递落库(最多 30 秒)。
  let log
  for (let i = 0; i < 30 && log === undefined; i += 1) {
    log = await db('email_delivery_logs').where({ to_email: email, template: 'verify-code', status: 'sent' }).orderBy('created_at', 'desc').first()
    if (log === undefined) await new Promise((resolve) => setTimeout(resolve, 1000))
  }
  check('验证码邮件已投递并记录', log !== undefined, { email })
  const code = JSON.parse(log.metadata_json).code

  const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en', handle: `sk-${Date.now()}` } })
  check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered)
  userId = registered.json.user.id
  const token = registered.json.access_token

  const me0 = await call('GET', '/api/voko/me', { token })
  check('新用户没有申请', me0.status === 200 && me0.json.access_request === null, me0)

  const submitted = await call('POST', '/api/voko/access-requests', { token, body: { note: 'skeleton scenario' } })
  check('提交申请 → pending', submitted.json.access_request.status === 'pending', submitted)
  const again = await call('POST', '/api/voko/access-requests', { token, body: {} })
  check('重复提交返回同一申请', again.json.access_request.id === submitted.json.access_request.id, again)

  const forbidden = await call('GET', '/api/voko/admin/access-requests', { token })
  check('普通用户访问后台 → 403', forbidden.status === 403, forbidden)

  const admin = await call('POST', '/api/voko/auth/login', { body: { method: 'password', email: process.env.VOKO_ADMIN_EMAIL, password: process.env.VOKO_ADMIN_PASSWORD } })
  check('admin login', admin.status === 200, admin)
  const queue = await call('GET', '/api/voko/admin/access-requests?status=pending&limit=200', { token: admin.json.access_token })
  check('排队列表里有这条申请', queue.json.access_requests.some((r) => r.id === submitted.json.access_request.id), queue.json)

  const approved = await call('POST', `/api/voko/admin/access-requests/${submitted.json.access_request.id}/review`, { token: admin.json.access_token, body: { decision: 'approved' } })
  check('审核通过', approved.status === 200 && approved.json.access_request.status === 'approved', approved)
  const twice = await call('POST', `/api/voko/admin/access-requests/${submitted.json.access_request.id}/review`, { token: admin.json.access_token, body: { decision: 'rejected' } })
  check('重复审核 → 409', twice.status === 409, twice)
  const notice = await db('email_delivery_logs').where({ to_email: email, purpose: 'access_approved' }).first()
  check('通过通知邮件已投递', notice !== undefined && notice.status === 'sent', notice)

  const ledger = createLedger(createBillingRepo(db))
  await ledger.grant({ user_id: userId, amount: 100, source: 'admin', reason: 'admin_adjustment', note: 'skeleton scenario', idempotency_key: `skeleton:grant:${userId}` })
  await ledger.deduct({ user_id: userId, amount: 30, source: 'admin', reason: 'skeleton', idempotency_key: `skeleton:deduct:${userId}` })
  const replay = await ledger.deduct({ user_id: userId, amount: 30, source: 'admin', reason: 'skeleton', idempotency_key: `skeleton:deduct:${userId}` })
  check('同一幂等键重放不重复扣', replay.replayed === true && replay.balance_after === 70, replay)
  const view = await db('points_balance_view').where({ user_id: userId }).first()
  check('余额视图 = 70', Number(view.balance) === 70, view)

  console.log(JSON.stringify({ status: 'PASS', email, results }, null, 2))
} catch (err) {
  console.log(JSON.stringify({ status: 'FAIL', email, results, error: err instanceof Error ? err.message : String(err) }, null, 2))
  process.exitCode = 1
} finally {
  if (userId !== null) {
    await db('billing_ledger').where({ user_id: userId }).del()
    await db('vk_access_requests').where({ user_id: userId }).del()
    await db('auth_sessions').where({ user_id: userId }).del()
    await db('vk_owner_handles').where({ user_id: userId }).del()
    await db('users').where({ id: userId }).del()
  }
  await db('auth_codes').where({ email }).del()
  await db('email_delivery_logs').where({ to_email: email }).del()
  await db.destroy()
}