feat: replace desktop membership with permanent point wallet

This commit is contained in:
2026-09-22 11:43:04 +08:00
parent 2f82b9f79c
commit a1cce428af
24 changed files with 1343 additions and 1707 deletions

View File

@@ -0,0 +1,128 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
type WalletFixture = { requests: string[]; paid: boolean; created: boolean };
type FixtureGlobal = typeof globalThis & { __pointWalletE2E: WalletFixture };
test.describe('Permanent point wallet', () => {
test('reopens an unpaid order, keeps its frozen amount, and refreshes only after confirmed credit', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
const applicationUrl = page.url();
await page.goto('about:blank');
await app.evaluate(({ ipcMain }) => {
const state: WalletFixture = { requests: [], paid: false, created: false };
(globalThis as FixtureGlobal).__pointWalletE2E = state;
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
const points = () => ({
total: state.paid ? '500.00' : '0.00', used: '0.00', reserved: '0.00',
total_remaining: state.paid ? '500.00' : '0.00', entitlement_source: 'self',
family_shared: false, can_recharge: true, shared_available: null, expires_at: null,
});
const order = () => ({
id: 'order-e2e', product_id: 'product-e2e', product_name: '500 词元点数',
status: state.paid ? 'succeeded' : 'pending', amount_cents: 1000, point_amount: '500.00',
currency: 'CNY', created_at: '2026-09-22T00:00:00Z',
paid_at: state.paid ? '2026-09-22T01:00:00Z' : null, manual_review_required: false,
qr_payload: state.paid ? null : 'weixin://wxpay/e2e-fixture-not-a-real-payment',
});
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
const path = request.path ?? '';
const method = (request.method ?? 'GET').toUpperCase();
state.requests.push(method + ' ' + path);
if (path === '/api/auth/session/sync') return result({
success: true, session: {
accessToken: 'point-wallet-fixture-token', tokenType: 'Bearer',
expiresAt: Date.now() + 3_600_000, lastActiveAt: Date.now(), canRefresh: false,
},
});
if (path === '/api/auth/me') return result({
success: true,
user: { username: 'point-wallet', userId: 'point-wallet-user', tenantId: null, deptId: null, authorities: [] },
moduleAccess: { programming: true, design: true, robot: true },
});
if (path === '/api/works/user/agent-profile') return result({
success: true, profile: {
display_name: '词元点数用户', age: null, gender: null, avatar_url: null,
share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true,
completed: true, version: 1, updated_at: '2026-09-22T00:00:00Z',
},
});
if (path === '/api/works/billing/me') return result({ success: true, data: {
own_points: points(), points: points(), funding_error: null,
can_recharge: true, payment_configured: true, signup_bonus: '100.00', points_per_yuan: 50,
recharge_products: [{
id: 'product-e2e', name: '词元点数',
amount_cents: state.created ? 2000 : 1000, point_amount: state.created ? '1000.00' : '500.00', currency: 'CNY',
}],
} });
if (path.startsWith('/api/works/billing/recharge/orders?')) return result({
success: true, data: { items: state.created ? [order()] : [] },
});
if (path === '/api/works/billing/recharge/orders' && method === 'POST') {
state.created = true;
return result({ success: true, data: order() });
}
if (path === '/api/works/billing/recharge/orders/order-e2e') return result({ success: true, data: order() });
if (path.startsWith('/api/works/billing/points/transactions?')) return result({
success: true, data: { has_more: false, items: state.paid ? [{
id: 'credit-e2e', kind: 'credit', description: '充值', status: 'credited',
points: '500.00', reserved_points: '0.00', created_at: '2026-09-22T01:00:00Z',
}] : [] },
});
return { ok: false, error: { message: 'Unexpected fixture request: ' + method + ' ' + path } };
});
});
await page.goto(applicationUrl, { waitUntil: 'domcontentloaded' });
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await expect(page.getByTestId('sidebar-member-menu-trigger')).toContainText('词元点数用户');
await page.getByTestId('sidebar-member-menu-trigger').click();
await expect(page.getByTestId('sidebar-total-token-points')).toHaveText('0 点');
await expect(page.getByText('点数已用尽,充值后可继续使用。')).toBeVisible();
await page.getByTestId('sidebar-point-wallet-open').click();
const wallet = page.getByTestId('point-wallet-dialog');
await expect(wallet).toContainText('1 元充值 50 点');
await expect(wallet).not.toContainText('重置卡');
await wallet.getByRole('button', { name: /500 点\s*¥10.00/ }).click();
await expect(wallet.getByAltText('充值支付二维码')).toBeVisible();
await expect(page.getByTestId('point-recharge-checkout')).toContainText('¥10.00 · 500 点');
await expect(wallet.getByRole('button', { name: /1,000 点\s*¥20.00/ })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('pending-permanent-points.png') });
// Reloading the Renderer must recover the existing server order using GET only.
await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByTestId('sidebar-member-menu-trigger').click();
await page.getByTestId('sidebar-point-wallet-open').click();
await wallet.getByRole('button', { name: '查看原订单' }).click();
await expect(wallet.getByAltText('充值支付二维码')).toBeVisible();
await app.evaluate(() => { (globalThis as FixtureGlobal).__pointWalletE2E.paid = true; });
await wallet.getByRole('button', { name: '刷新此订单' }).click();
await expect(page.getByTestId('point-recharge-checkout')).toContainText('已到账');
await expect(wallet.getByAltText('充值支付二维码')).toHaveCount(0);
await expect(wallet).toContainText('500 点');
await wallet.getByRole('tab', { name: '收支记录' }).click();
await expect(wallet).toContainText('+500 点');
await expect(wallet).toContainText('已入账');
await page.screenshot({ path: testInfo.outputPath('credited-permanent-points.png') });
expect(await app.evaluate(() => (globalThis as FixtureGlobal).__pointWalletE2E.requests.filter(
(request) => request === 'POST /api/works/billing/recharge/orders',
).length)).toBe(1);
} catch (error) {
const page = app.windows().at(-1);
if (page && !page.isClosed()) {
await page.screenshot({ path: testInfo.outputPath('wallet-failure.png') });
await testInfo.attach('wallet-failure-dom', { body: await page.locator('body').innerText(), contentType: 'text/plain' });
}
await testInfo.attach('wallet-fixture-requests', {
body: JSON.stringify(await app.evaluate(() => (globalThis as FixtureGlobal).__pointWalletE2E.requests)),
contentType: 'application/json',
});
throw error;
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -1,126 +0,0 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test.describe('Account reset-card wallet', () => {
test('hides a redeemed card and refreshes the authoritative point balance', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
const applicationUrl = page.url();
await page.goto('about:blank');
await app.evaluate(({ ipcMain }) => {
const requests: string[] = [];
let redeemed = false;
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
const points = () => ({
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-08T00:00:00Z',
cycle_end: '2026-09-15T00:00:00Z',
next_refresh_at: '2026-09-15T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: redeemed ? '0.00' : '400.00',
weekly_reserved: '0.00',
weekly_remaining: redeemed ? '500.00' : '100.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: redeemed ? '550.00' : '150.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
});
const card = () => ({
id: 'card-e2e',
status: redeemed ? 'redeemed' : 'available',
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: redeemed ? '2026-09-09T00:00:00Z' : null,
redeemed_cycle_id: redeemed ? 'cycle-e2e' : null,
});
(globalThis as typeof globalThis & { __resetCardE2E?: { requests: string[] } }).__resetCardE2E = { requests };
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
const path = request.path ?? '';
const method = (request.method ?? 'GET').toUpperCase();
requests.push(`${method} ${path}`);
if (path === '/api/auth/session/sync') return result({
success: true,
session: {
accessToken: 'reset-card-e2e-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3_600_000,
lastActiveAt: Date.now(),
canRefresh: false,
},
});
if (path === '/api/auth/me') return result({
success: true,
user: {
username: 'reset-card-e2e',
userId: 'reset-card-e2e-user',
tenantId: null,
deptId: null,
authorities: [],
},
moduleAccess: { programming: true, design: true, robot: true },
});
if (path === '/api/works/user/agent-profile') return result({
success: true,
profile: {
display_name: '重置卡用户',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-09-08T00:00:00Z',
},
});
if (path === '/api/works/billing/points') return result({ success: true, points: points() });
if (path === '/api/works/billing/reset-cards' && method === 'GET') {
return result({ success: true, cards: [card()] });
}
if (path === '/api/works/billing/reset-cards/card-e2e/redeem' && method === 'POST') {
redeemed = true;
return result({ success: true, card: card() });
}
return { ok: false, error: { message: `Unexpected Host API request: ${method} ${path}` } };
});
});
await page.goto(applicationUrl, { waitUntil: 'domcontentloaded' });
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.getByTestId('sidebar-member-menu-trigger').click();
await page.getByTestId('sidebar-reset-cards-menuitem').click();
await expect.poll(async () => app.evaluate(() => (
(globalThis as typeof globalThis & { __resetCardE2E?: { requests: string[] } }).__resetCardE2E?.requests
.filter((request) => request === 'GET /api/works/billing/reset-cards').length ?? 0
))).toBeGreaterThan(0);
await expect(page.getByTestId('sidebar-reset-card-card-e2e')).toContainText('可使用');
await expect(page.getByTestId('sidebar-reset-card-card-e2e')).toContainText('有效期至 2099-09-15');
await page.getByTestId('sidebar-reset-card-redeem-card-e2e').click();
await expect(page.getByTestId('sidebar-reset-card-card-e2e')).toHaveCount(0);
await expect(page.getByTestId('sidebar-reset-cards-drawer')).toContainText('暂无未使用的重置卡。');
await expect(page.getByTestId('sidebar-reset-card-available-count')).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('used-reset-card-hidden.png') });
await page.getByTestId('sidebar-account-usage-menuitem').click();
await expect(page.getByTestId('sidebar-weekly-token-points')).toContainText('500 / 500 点');
await expect.poll(async () => app.evaluate(() => (
(globalThis as typeof globalThis & { __resetCardE2E?: { requests: string[] } }).__resetCardE2E?.requests
.filter((request) => request === 'POST /api/works/billing/reset-cards/card-e2e/redeem').length ?? 0
))).toBe(1);
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -0,0 +1,298 @@
{
"before": {
"points": {
"total": "0.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "0.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"funding_error": null,
"own_points": {
"total": "0.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "0.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"recharge_products": [
{
"id": "contract-product",
"code": "recharge_10",
"name": "500 词元点数",
"amount_cents": 1000,
"point_amount": "500.00",
"currency": "CNY",
"is_active": true,
"sort_order": 0
}
],
"points_per_yuan": 50,
"signup_bonus": "100.00",
"payment_configured": false,
"can_recharge": true
},
"created": {
"order": {
"id": "8da6bb6f-5bf9-4ef5-92a4-11b89e36d0e5",
"product_id": "contract-product",
"product_name": "500 词元点数",
"status": "pending",
"amount_cents": 1000,
"point_amount": "500.00",
"currency": "CNY",
"created_at": "2026-09-22T03:34:36",
"paid_at": null,
"failure_reason": null,
"manual_review_required": false,
"provider_payload": {
"orderNo": "contract-payment",
"codeUrl": "weixin://wxpay/contract-fixture"
}
},
"provider": "one_feel_trade",
"provider_payload": {
"orderNo": "contract-payment",
"codeUrl": "weixin://wxpay/contract-fixture"
}
},
"pending": {
"id": "8da6bb6f-5bf9-4ef5-92a4-11b89e36d0e5",
"product_id": "contract-product",
"product_name": "500 词元点数",
"status": "pending",
"amount_cents": 1000,
"point_amount": "500.00",
"currency": "CNY",
"created_at": "2026-09-22T03:34:36",
"paid_at": null,
"failure_reason": null,
"manual_review_required": false,
"provider_payload": {
"orderNo": "contract-payment",
"codeUrl": "weixin://wxpay/contract-fixture"
}
},
"orders": {
"items": [
{
"id": "8da6bb6f-5bf9-4ef5-92a4-11b89e36d0e5",
"product_id": "contract-product",
"product_name": "500 词元点数",
"status": "pending",
"amount_cents": 1000,
"point_amount": "500.00",
"currency": "CNY",
"created_at": "2026-09-22T03:34:36",
"paid_at": null,
"failure_reason": null,
"manual_review_required": false,
"provider_payload": {
"orderNo": "contract-payment",
"codeUrl": "weixin://wxpay/contract-fixture"
}
}
]
},
"repriced": {
"points": {
"total": "0.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "0.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"funding_error": null,
"own_points": {
"total": "0.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "0.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"recharge_products": [
{
"id": "contract-product",
"code": "recharge_10",
"name": "500 词元点数",
"amount_cents": 2000,
"point_amount": "1000.00",
"currency": "CNY",
"is_active": true,
"sort_order": 0
}
],
"points_per_yuan": 50,
"signup_bonus": "100.00",
"payment_configured": false,
"can_recharge": true
},
"paid": {
"id": "8da6bb6f-5bf9-4ef5-92a4-11b89e36d0e5",
"product_id": "contract-product",
"product_name": "500 词元点数",
"status": "succeeded",
"amount_cents": 1000,
"point_amount": "500.00",
"currency": "CNY",
"created_at": "2026-09-22T03:34:36",
"paid_at": "2026-09-22T03:34:36.651519",
"failure_reason": null,
"manual_review_required": false,
"provider_payload": null
},
"after": {
"points": {
"total": "500.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "500.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"funding_error": null,
"own_points": {
"total": "500.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "500.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"recharge_products": [
{
"id": "contract-product",
"code": "recharge_10",
"name": "500 词元点数",
"amount_cents": 2000,
"point_amount": "1000.00",
"currency": "CNY",
"is_active": true,
"sort_order": 0
}
],
"points_per_yuan": 50,
"signup_bonus": "100.00",
"payment_configured": false,
"can_recharge": true
},
"history": {
"items": [
{
"id": "62528c3d-6c41-4bc9-9fbf-bb03aebb5b44",
"kind": "credit",
"source": "point_pack",
"description": "充值到账",
"status": "credited",
"points": "500.00",
"reserved_points": "0.00",
"created_at": "2026-09-22T03:34:36"
}
],
"has_more": false
},
"youth": {
"points": {
"total": "100.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "100.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": false,
"shared_available": null,
"expires_at": null
},
"funding_error": null,
"own_points": {
"total": "100.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "100.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": false,
"shared_available": null,
"expires_at": null
},
"recharge_products": [
{
"id": "contract-product",
"code": "recharge_10",
"name": "500 词元点数",
"amount_cents": 2000,
"point_amount": "1000.00",
"currency": "CNY",
"is_active": true,
"sort_order": 0
}
],
"points_per_yuan": 50,
"signup_bonus": "100.00",
"payment_configured": false,
"can_recharge": false
},
"family_payer": {
"points": {
"total": "500.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "500.00",
"entitlement_source": "self",
"family_shared": true,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"funding_error": null,
"own_points": {
"total": "500.00",
"used": "0.00",
"reserved": "0.00",
"total_remaining": "500.00",
"entitlement_source": "self",
"family_shared": false,
"can_recharge": true,
"shared_available": null,
"expires_at": null
},
"recharge_products": [
{
"id": "contract-product",
"code": "recharge_10",
"name": "500 词元点数",
"amount_cents": 2000,
"point_amount": "1000.00",
"currency": "CNY",
"is_active": true,
"sort_order": 0
}
],
"points_per_yuan": 50,
"signup_bonus": "100.00",
"payment_configured": false,
"can_recharge": true
}
}

11
tests/fixtures/permanent-points.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import type { WorksBillingSummary, WorksRechargeOrder, WorksTokenPointBalance } from '../../shared/works-billing';
export function permanentBalance(overrides: Partial<WorksTokenPointBalance> = {}): WorksTokenPointBalance {
return { total: '100.00', used: '20.00', reserved: '5.00', total_remaining: '75.00', entitlement_source: 'self', family_shared: false, can_recharge: true, shared_available: null, expires_at: null, ...overrides };
}
export function billingSummary(overrides: Partial<WorksBillingSummary> = {}): WorksBillingSummary {
return { own_points: permanentBalance(), points: permanentBalance(), funding_error: null, recharge_products: [{ id: 'recharge-10', name: '500 词元点数', amount_cents: 1000, point_amount: '500.00', currency: 'CNY' }], points_per_yuan: 50, signup_bonus: '100.00', can_recharge: true, payment_configured: true, ...overrides };
}
export function rechargeOrder(overrides: Partial<WorksRechargeOrder> = {}): WorksRechargeOrder {
return { id: 'order-1', product_id: 'recharge-10', product_name: '500 词元点数', status: 'pending', amount_cents: 1000, point_amount: '500.00', currency: 'CNY', created_at: '2026-09-22T00:00:00Z', paid_at: null, manual_review_required: false, qr_payload: 'weixin://wxpay/bizpayurl?pr=test', ...overrides };
}

View File

@@ -0,0 +1,116 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PointWallet } from '@/components/account/PointWallet';
import { AppError } from '@/lib/error-model';
import { billingSummary, permanentBalance, rechargeOrder } from '../fixtures/permanent-points';
const api = vi.hoisted(() => ({ summary: vi.fn(), orders: vi.fn(), history: vi.fn(), create: vi.fn(), order: vi.fn(), external: vi.fn() }));
vi.mock('@/lib/works-billing', () => ({ fetchWorksBillingSummary: api.summary, fetchWorksRechargeOrders: api.orders, fetchWorksPointHistory: api.history, createWorksRechargeOrder: api.create, fetchWorksRechargeOrder: api.order, openWorksBillingAccount: api.external }));
vi.mock('@/lib/api-client', () => ({ invokeIpc: api.external }));
vi.mock('qrcode', () => ({ default: { toDataURL: vi.fn(async () => 'data:image/png;base64,test') } }));
beforeEach(() => {
Object.values(api).forEach((mock) => mock.mockReset());
api.summary.mockResolvedValue(billingSummary()); api.orders.mockResolvedValue({ items: [] });
api.history.mockResolvedValue({ items: [], has_more: false }); api.create.mockResolvedValue(rechargeOrder()); api.external.mockResolvedValue(undefined);
});
async function openWallet() {
render(<PointWallet />);
await screen.findByText('75 点');
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
const dialog = screen.getByTestId('point-wallet-dialog');
await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled());
return dialog;
}
describe('permanent point wallet', () => {
it('shows personal permanent balance without membership, cycle or reset-card controls', async () => {
const dialog = await openWallet();
expect(dialog).toHaveTextContent('永久有效');
expect(screen.queryByText(/重置卡|升级订阅|本周剩余/)).not.toBeInTheDocument();
});
it('shows youth own precision and shared availability without enabling recharge', async () => {
api.summary.mockResolvedValue(billingSummary({ can_recharge: false, payment_configured: false, own_points: permanentBalance({ can_recharge: false }), points: permanentBalance({ total: null, used: null, reserved: null, total_remaining: null, family_shared: true, entitlement_source: 'family_owner', shared_available: false, can_recharge: false }) }));
render(<PointWallet />); await screen.findByText('75 点');
expect(screen.getByText(/AI 编程使用共享点数/)).toHaveTextContent('已用尽');
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
await screen.findByText(/仅家长身份可以充值/);
expect(screen.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeDisabled();
});
it('does not mistake the family payer’s own shared wallet for an exhausted external wallet', async () => {
api.summary.mockResolvedValue(billingSummary({ points: permanentBalance({ family_shared: true, entitlement_source: 'shared_group' }) }));
await openWallet();
expect(screen.queryByText(/AI 编程使用共享点数|已用尽/)).not.toBeInTheDocument();
});
it('does not duplicate rapid checkout and displays the frozen original order after repricing', async () => {
const dialog = await openWallet();
let resolve!: (value: ReturnType<typeof rechargeOrder>) => void;
api.create.mockReturnValue(new Promise((done) => { resolve = done; }));
const buy = within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ });
fireEvent.click(buy); fireEvent.click(buy);
expect(api.create).toHaveBeenCalledTimes(1);
const changed = billingSummary(); changed.recharge_products[0] = { ...changed.recharge_products[0], point_amount: '1000.00', amount_cents: 2000 };
api.summary.mockResolvedValue(changed); api.orders.mockResolvedValue({ items: [rechargeOrder()] });
await act(async () => resolve(rechargeOrder()));
await screen.findByAltText('充值支付二维码');
expect(screen.getByTestId('point-recharge-checkout')).toHaveTextContent('¥10.00 · 500 点');
expect(within(dialog).queryByRole('button', { name: /1,000 点\s*¥20.00/ })).not.toBeInTheDocument();
});
it('reuses an unknown request identity and never automatically retries payment', async () => {
const dialog = await openWallet(); api.create.mockRejectedValueOnce(new Error('lost response'));
fireEvent.click(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ }));
const retry = await screen.findByRole('button', { name: '按原请求重试' });
await waitFor(() => expect(retry).toBeEnabled());
expect(api.create).toHaveBeenCalledTimes(1);
fireEvent.click(retry); await screen.findByTestId('point-recharge-checkout');
expect(api.create.mock.calls[1]).toEqual(api.create.mock.calls[0]);
});
it('permits a fresh explicit intent only after a definitive rejection', async () => {
const dialog = await openWallet(); api.create.mockRejectedValueOnce(new AppError('NETWORK', 'rejected', undefined, { commandOutcome: 'definitive_failure' }));
fireEvent.click(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ }));
await screen.findByRole('alert');
const buy = within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ });
await waitFor(() => expect(buy).toBeEnabled()); fireEvent.click(buy);
await screen.findByTestId('point-recharge-checkout');
expect(api.create.mock.calls[1][1]).not.toBe(api.create.mock.calls[0][1]);
});
it('loads an existing pending order without initiating another payment and accepts only server confirmation', async () => {
api.orders.mockResolvedValue({ items: [rechargeOrder()] });
render(<PointWallet />); await screen.findByText('75 点'); fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
fireEvent.click(await screen.findByRole('button', { name: '查看原订单' }));
await screen.findByAltText('充值支付二维码');
api.order.mockResolvedValue(rechargeOrder({ status: 'succeeded', qr_payload: null, paid_at: '2026-09-22T01:00:00Z' }));
api.summary.mockResolvedValue(billingSummary({ own_points: permanentBalance({ total_remaining: '575.00' }) }));
api.orders.mockResolvedValue({ items: [rechargeOrder({ status: 'succeeded', qr_payload: null })] });
fireEvent.click(screen.getByRole('button', { name: '刷新此订单' }));
await screen.findByText('575 点');
expect(screen.queryByAltText('充值支付二维码')).not.toBeInTheDocument(); expect(api.create).not.toHaveBeenCalled();
});
it('pages signed usage history and preserves pending-review holds', async () => {
const dialog = await openWallet();
api.history.mockResolvedValue({ items: [{ id: 'usage', kind: 'usage', description: 'AI 编程', status: 'pending_review', points: '0.00', reserved_points: '1.50', created_at: '2026-09-22T00:00:00Z' }], has_more: true });
fireEvent.click(within(dialog).getByRole('tab', { name: '收支记录' }));
await screen.findByText(/预占 1.5 点/); fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(api.history).toHaveBeenCalledWith(20));
});
it('blocks recharge on unreadable orders and shows manual review without a payment QR', async () => {
api.orders.mockRejectedValueOnce(new Error('offline'));
render(<PointWallet />); await screen.findByText('75 点');
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
await screen.findByText('记录加载失败,请刷新后再充值。');
expect(screen.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeDisabled();
api.orders.mockResolvedValue({ items: [rechargeOrder({ status: 'manual_review', manual_review_required: true, qr_payload: null })] });
fireEvent.click(screen.getByRole('button', { name: '刷新', exact: true }));
fireEvent.click(await screen.findByRole('button', { name: '查看原订单' }));
await screen.findByText('付款结果需要运营核查,请勿再次付款。');
expect(screen.queryByAltText('充值支付二维码')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
});
it('discarded account components cannot receive an old balance or checkout response', async () => {
let resolve!: (value: ReturnType<typeof billingSummary>) => void;
api.summary.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const view = render(<PointWallet key="account-a" />);
api.summary.mockResolvedValue(billingSummary({ own_points: permanentBalance({ total_remaining: '2.00' }) }));
view.rerender(<PointWallet key="account-b" />); await screen.findByText('2 点');
await act(async () => resolve(billingSummary({ own_points: permanentBalance({ total_remaining: '999.00' }) })));
expect(screen.queryByText('999 点')).not.toBeInTheDocument();
});
});

View File

@@ -1,12 +1,10 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '@/components/layout/Sidebar';
import type { WorksResetCard, WorksTokenPointBalance } from '@/lib/works-square';
import { billingSummary, permanentBalance } from '../fixtures/permanent-points';
const fetchTokenPointBalanceMock = vi.hoisted(() => vi.fn());
const fetchResetCardsMock = vi.hoisted(() => vi.fn());
const redeemResetCardMock = vi.hoisted(() => vi.fn());
const fetchBillingSummaryMock = vi.hoisted(() => vi.fn());
const getValidAccessTokenMock = vi.hoisted(() => vi.fn());
const authState = vi.hoisted(() => ({
user: {
@@ -36,13 +34,11 @@ const projectConfigState = vi.hoisted(() => ({
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
}));
vi.mock('@/lib/works-square', () => ({
fetchWorksTokenPointBalance: (...args: unknown[]) => fetchTokenPointBalanceMock(...args),
fetchWorksResetCards: (...args: unknown[]) => fetchResetCardsMock(...args),
redeemWorksResetCard: (...args: unknown[]) => redeemResetCardMock(...args),
}));
vi.mock('@/lib/subscription-upgrade', () => ({
openWorksSquareSubscriptionUpgrade: vi.fn().mockResolvedValue(undefined),
vi.mock('@/lib/works-billing', () => ({
fetchWorksBillingSummary: (...args: unknown[]) => fetchBillingSummaryMock(...args),
fetchWorksRechargeOrders: vi.fn().mockResolvedValue({ items: [] }),
fetchWorksPointHistory: vi.fn().mockResolvedValue({ items: [], has_more: false }),
createWorksRechargeOrder: vi.fn(), fetchWorksRechargeOrder: vi.fn(), openWorksBillingAccount: vi.fn(),
}));
vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
vi.mock('@/hooks/use-current-user-profile', () => ({
@@ -76,273 +72,40 @@ vi.mock('@/stores/providers', () => ({
}));
vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null }));
vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null }));
vi.mock('@/components/layout/LearningSidebar', () => ({ LearningSidebar: () => null }));
vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null }));
vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null }));
function pointBalance(
overrides: Partial<WorksTokenPointBalance> = {},
): WorksTokenPointBalance {
return {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
...overrides,
};
}
function resetCard(overrides: Partial<WorksResetCard> = {}): WorksResetCard {
return {
id: 'card-1',
status: 'available',
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
...overrides,
};
}
async function renderUsageDrawer(): Promise<HTMLElement> {
render(
<MemoryRouter initialEntries={['/learning']}>
<Sidebar />
</MemoryRouter>,
);
await waitFor(() => expect(getValidAccessTokenMock).toHaveBeenCalled());
async function openAccount() {
render(<MemoryRouter initialEntries={['/coding']}><Sidebar /></MemoryRouter>);
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByTestId('sidebar-account-usage-menuitem'));
return screen.getByTestId('sidebar-account-usage-drawer');
}
async function renderResetCardDrawer(): Promise<HTMLElement> {
render(
<MemoryRouter initialEntries={['/learning']}>
<Sidebar />
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByTestId('sidebar-reset-cards-menuitem'));
return screen.getByTestId('sidebar-reset-cards-drawer');
}
describe('Sidebar V2 token point balance', () => {
describe('Sidebar permanent point wallet', () => {
beforeEach(() => {
getValidAccessTokenMock.mockReset();
fetchTokenPointBalanceMock.mockReset();
fetchResetCardsMock.mockReset();
redeemResetCardMock.mockReset();
getValidAccessTokenMock.mockResolvedValue('access-token');
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
fetchResetCardsMock.mockResolvedValue([]);
fetchBillingSummaryMock.mockReset();
fetchBillingSummaryMock.mockResolvedValue(billingSummary());
});
it('shows the authoritative weekly and total point balances without the retired rolling rows', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
const drawer = await renderUsageDrawer();
await waitFor(() => expect(within(drawer).getByText('精通')).toBeInTheDocument());
expect(drawer).toHaveTextContent('本周剩余');
expect(screen.getByTestId('sidebar-weekly-token-points')).toHaveTextContent('375 / 500 点');
expect(drawer).toHaveTextContent('总可用');
expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('425 点');
expect(drawer).not.toHaveTextContent('5 小时');
expect(drawer).not.toHaveTextContent('1 周');
it('opens the point wallet from the account menu without retired member controls', async () => {
await openAccount();
await waitFor(() => expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'));
expect(screen.queryByText(/重置卡|升级订阅|会员等级|本周剩余/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
expect(screen.getByTestId('point-wallet-dialog')).toHaveTextContent('1 元充值 50 点');
});
it('shows a distinct retrieval error instead of treating missing data as unlimited or zero', async () => {
fetchTokenPointBalanceMock.mockRejectedValue(new Error('upstream unavailable'));
const drawer = await renderUsageDrawer();
await waitFor(() => expect(screen.getByTestId('sidebar-token-points-error')).toBeInTheDocument());
expect(drawer).toHaveTextContent('词元点数暂时无法获取。');
expect(drawer).not.toHaveTextContent('不限');
expect(drawer).not.toHaveTextContent('0%');
it('shows a retryable failure without inventing a zero balance', async () => {
fetchBillingSummaryMock.mockRejectedValue(new Error('offline'));
await openAccount();
await waitFor(() => expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('暂时不可用'));
fetchBillingSummaryMock.mockResolvedValue(billingSummary());
fireEvent.click(screen.getByRole('button', { name: '重试余额' }));
await waitFor(() => expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'));
});
it('shows only coarse availability for a family-shared member', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({
plan_code: null,
plan_name: null,
entitlement_source: 'shared_group',
family_shared: true,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: false,
weekly_allowance: null,
weekly_used: null,
weekly_reserved: null,
weekly_remaining: null,
permanent_total: null,
permanent_used: null,
permanent_reserved: null,
permanent_remaining: null,
total_remaining: null,
}));
const drawer = await renderUsageDrawer();
await waitFor(() => expect(within(drawer).getByText('共享会员')).toBeInTheDocument());
expect(drawer).toHaveTextContent('共享额度');
expect(drawer).toHaveTextContent('已用尽');
expect(drawer).toHaveTextContent('请联系家庭管理员');
expect(drawer).not.toHaveTextContent('本周剩余');
expect(drawer).not.toHaveTextContent('总可用');
expect(screen.queryByTestId('sidebar-account-upgrade-button')).not.toBeInTheDocument();
});
it('shows only parent-managed availability for a youth self entitlement', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({
plan_code: null,
plan_name: null,
entitlement_source: 'self',
family_shared: false,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: false,
weekly_allowance: null,
weekly_used: null,
weekly_reserved: null,
weekly_remaining: null,
permanent_total: null,
permanent_used: null,
permanent_reserved: null,
permanent_remaining: null,
total_remaining: null,
}));
const drawer = await renderUsageDrawer();
await waitFor(() => expect(within(drawer).getByText('青少年账户')).toBeInTheDocument());
expect(drawer).toHaveTextContent('词元点数');
expect(drawer).toHaveTextContent('已用尽');
expect(drawer).toHaveTextContent('请联系家长');
expect(drawer).not.toHaveTextContent('共享额度');
expect(drawer).not.toHaveTextContent('本周剩余');
expect(drawer).not.toHaveTextContent('总可用');
expect(screen.queryByTestId('sidebar-account-upgrade-button')).not.toBeInTheDocument();
});
it('shows available and expired reset cards while hiding redeemed cards', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
fetchResetCardsMock.mockResolvedValue([
resetCard(),
resetCard({ id: 'card-expired', status: 'expired', expires_at: '2026-09-01T00:00:00Z' }),
resetCard({
id: 'card-redeemed',
status: 'redeemed',
redeemed_at: '2026-09-09T00:00:00Z',
redeemed_cycle_id: 'cycle-new',
}),
]);
const drawer = await renderResetCardDrawer();
await waitFor(() => expect(within(drawer).getByText('可使用')).toBeInTheDocument());
expect(screen.getByTestId('sidebar-reset-card-available-count')).toHaveTextContent('1');
expect(drawer).toHaveTextContent('已过期');
expect(screen.queryByTestId('sidebar-reset-card-card-redeemed')).not.toBeInTheDocument();
expect(drawer).toHaveTextContent('有效期至 2099-09-15');
expect(drawer).not.toHaveTextContent('使用于');
expect(screen.getByTestId('sidebar-reset-card-redeem-card-1')).toHaveTextContent('立即使用');
expect(screen.queryByTestId('sidebar-reset-card-redeem-card-expired')).not.toBeInTheDocument();
});
it('shows an empty wallet when all cards were already redeemed', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
fetchResetCardsMock.mockResolvedValue([resetCard({ status: 'redeemed', redeemed_at: '2026-09-09T00:00:00Z' })]);
const drawer = await renderResetCardDrawer();
await waitFor(() => expect(drawer).toHaveTextContent('暂无未使用的重置卡。'));
expect(screen.queryByTestId('sidebar-reset-card-card-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('sidebar-reset-card-available-count')).not.toBeInTheDocument();
expect(redeemResetCardMock).not.toHaveBeenCalled();
});
it('hides a redeemed card and refreshes both the card wallet and point balance', async () => {
const available = resetCard();
const redeemed = resetCard({
status: 'redeemed',
redeemed_at: '2026-09-09T00:00:00Z',
redeemed_cycle_id: 'cycle-new',
});
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
fetchResetCardsMock
.mockResolvedValueOnce([available])
.mockResolvedValue([redeemed]);
redeemResetCardMock.mockResolvedValue(redeemed);
const drawer = await renderResetCardDrawer();
await waitFor(() => expect(within(drawer).getByText('立即使用')).toBeInTheDocument());
const balanceCallsBeforeRedeem = fetchTokenPointBalanceMock.mock.calls.length;
fireEvent.click(screen.getByTestId('sidebar-reset-card-redeem-card-1'));
await waitFor(() => expect(redeemResetCardMock).toHaveBeenCalledWith('access-token', 'card-1'));
await waitFor(() => expect(screen.queryByTestId('sidebar-reset-card-card-1')).not.toBeInTheDocument());
await waitFor(() => expect(drawer).toHaveTextContent('暂无未使用的重置卡。'));
expect(screen.queryByTestId('sidebar-reset-card-available-count')).not.toBeInTheDocument();
expect(fetchResetCardsMock.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(fetchTokenPointBalanceMock.mock.calls.length).toBeGreaterThan(balanceCallsBeforeRedeem);
});
it('derives an elapsed available card as expired before offering redemption', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
fetchResetCardsMock.mockResolvedValue([
resetCard({ id: 'card-stale', expires_at: '2000-01-01T00:00:00Z' }),
]);
const drawer = await renderResetCardDrawer();
await waitFor(() => expect(within(drawer).getByText('已过期')).toBeInTheDocument());
expect(screen.queryByTestId('sidebar-reset-card-available-count')).not.toBeInTheDocument();
expect(screen.queryByTestId('sidebar-reset-card-redeem-card-stale')).not.toBeInTheDocument();
});
it('keeps a legacy owned card visible but disables redemption while using a shared wallet', async () => {
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({
plan_code: null,
plan_name: null,
entitlement_source: 'shared_group',
family_shared: true,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: true,
weekly_allowance: null,
weekly_used: null,
weekly_reserved: null,
weekly_remaining: null,
permanent_total: null,
permanent_used: null,
permanent_reserved: null,
permanent_remaining: null,
total_remaining: null,
}));
fetchResetCardsMock.mockResolvedValue([resetCard()]);
const drawer = await renderResetCardDrawer();
await waitFor(() => expect(within(drawer).getByText('家庭共享中不可用')).toBeDisabled());
expect(drawer).toHaveTextContent('请由家庭管理员使用自己的重置卡');
fireEvent.click(screen.getByTestId('sidebar-reset-card-redeem-card-1'));
expect(redeemResetCardMock).not.toHaveBeenCalled();
it('refreshes the balance when reopening the account menu after consumption', async () => {
await openAccount();
await waitFor(() => expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'));
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
fetchBillingSummaryMock.mockResolvedValue(billingSummary({ own_points: permanentBalance({ total_remaining: '50.00' }) }));
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
await waitFor(() => expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('50 点'));
});
});

View File

@@ -1,21 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
const invokeIpcMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock('@/lib/api-client', () => ({
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
}));
import { openWorksSquareSubscriptionUpgrade, WORKS_SQUARE_UPGRADE_URL } from '@/lib/subscription-upgrade';
describe('subscription upgrade helpers', () => {
it('opens the Works Square subscription page', async () => {
await openWorksSquareSubscriptionUpgrade();
expect(WORKS_SQUARE_UPGRADE_URL).toBe('https://square.nianxx.cn/#profile');
expect(invokeIpcMock).toHaveBeenCalledWith(
'shell:openExternal',
'https://square.nianxx.cn/#profile',
);
});
});

View File

@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createWorksRechargeOrder, fetchWorksBillingSummary, fetchWorksPointHistory, fetchWorksRechargeOrder, openWorksBillingAccount } from '@/lib/works-billing';
import { billingSummary, rechargeOrder } from '../fixtures/permanent-points';
const host = vi.hoisted(() => ({ fetch: vi.fn(), ipc: vi.fn() }));
vi.mock('@/lib/host-api', () => ({ hostApiFetch: host.fetch }));
vi.mock('@/lib/api-client', () => ({ invokeIpc: host.ipc }));
beforeEach(() => { host.fetch.mockReset(); host.ipc.mockReset(); });
describe('Main-owned billing client', () => {
it('reads the current Main session wallet without Renderer credentials', async () => {
const summary = billingSummary();
host.fetch.mockResolvedValue({ success: true, data: summary });
await expect(fetchWorksBillingSummary()).resolves.toEqual(summary);
expect(host.fetch).toHaveBeenCalledWith('/api/works/billing/me', undefined);
});
it('sends only the selected product and explicit attempt identity', async () => {
const order = rechargeOrder();
host.fetch.mockResolvedValue({ success: true, data: order });
await expect(createWorksRechargeOrder('recharge-10', 'attempt-1')).resolves.toEqual(order);
expect(host.fetch).toHaveBeenCalledWith('/api/works/billing/recharge/orders', {
method: 'POST', body: JSON.stringify({ productId: 'recharge-10', requestId: 'attempt-1' }),
});
});
it('encodes order ids and requests the selected ledger page', async () => {
host.fetch.mockResolvedValue({ success: true, data: {} });
await fetchWorksRechargeOrder('order one');
await fetchWorksPointHistory(20);
expect(host.fetch).toHaveBeenNthCalledWith(1, '/api/works/billing/recharge/orders/order%20one', undefined);
expect(host.fetch).toHaveBeenNthCalledWith(2, '/api/works/billing/points/transactions?offset=20', undefined);
});
it('opens the fixed web account page for payment-source management', async () => {
await openWorksBillingAccount();
expect(host.ipc).toHaveBeenCalledWith('shell:openExternal', 'https://square.nianxx.cn/#profile');
});
});

View File

@@ -0,0 +1,107 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handleWorksBillingRoutes, projectBillingBalance, projectBillingSummary, projectPointHistory, projectRechargeOrder } from '@electron/api/routes/works-billing';
import { getRendererCapability, RENDERER_CAPABILITY_HEADER, rotateRendererCapability } from '@electron/api/renderer-capability';
import { billingSummary, permanentBalance, rechargeOrder } from '../fixtures/permanent-points';
const net = vi.hoisted(() => vi.fn());
const token = vi.hoisted(() => vi.fn());
const current = vi.hoisted(() => vi.fn());
vi.mock('@electron/utils/proxy-fetch', () => ({ proxyAwareFetch: net }));
vi.mock('@electron/services/works-square-session', () => ({
getValidWorksSquareAccessToken: token,
getWorksSquareAccountBinding: () => ({ accountKey: 'main-account', epoch: 1 }),
isCurrentWorksSquareAccountBinding: current,
}));
async function call(path: string, method = 'GET', body?: unknown, capability = true) {
const req = Object.assign(new EventEmitter(), {
method, headers: capability ? { [RENDERER_CAPABILITY_HEADER]: getRendererCapability() } : {},
async *[Symbol.asyncIterator]() { if (body !== undefined) yield Buffer.from(JSON.stringify(body)); },
}) as IncomingMessage;
let responseBody = '';
const res = { statusCode: 0, setHeader: vi.fn(), end: (value: string) => { responseBody = value; } } as unknown as ServerResponse;
await handleWorksBillingRoutes(req, res, new URL(`http://localhost/api/works/billing${path}`));
return { status: res.statusCode, body: JSON.parse(responseBody) };
}
function reply(value: unknown, status = 200) { net.mockResolvedValueOnce(new Response(JSON.stringify(value), { status })); }
beforeEach(() => { net.mockReset(); token.mockReset().mockResolvedValue('main-only-secret'); current.mockReset().mockReturnValue(true); rotateRendererCapability(); });
describe('permanent points Main contract', () => {
it('keeps exact own youth balance and strips legacy/internal fields', () => {
expect(projectBillingBalance({ ...permanentBalance({ can_recharge: false }), plan_name: 'old', wallet_owner_id: 'private' })).toEqual(permanentBalance({ can_recharge: false }));
});
it('redacts exact shared values even when upstream accidentally includes them', () => {
expect(projectBillingBalance(permanentBalance({ family_shared: true, entitlement_source: 'shared_group', shared_available: false }))).toEqual({ total: null, used: null, reserved: null, total_remaining: null, entitlement_source: 'shared_group', family_shared: true, shared_available: false, can_recharge: false, expires_at: null });
});
it('rejects an old or expiring balance instead of displaying a made-up zero', () => {
expect(() => projectBillingBalance({ plan_code: 'experience', total_remaining: '10.00' })).toThrow();
expect(() => projectBillingBalance({ ...permanentBalance(), expires_at: '2099-01-01' })).toThrow();
});
it.each(['self', 'shared_group'] as const)('keeps the payer’s own exact balance with shared funding source %s', (source) => {
const owner = permanentBalance({ entitlement_source: source, family_shared: true, shared_available: null });
expect(projectBillingBalance(owner)).toEqual(owner);
});
it('checks the fixed recharge ratio and accepts a separate own wallet during funding failure', () => {
const summary = billingSummary({ points: null, funding_error: 'private upstream trace' });
expect(projectBillingSummary(summary).own_points.total_remaining).toBe('75.00');
expect(projectBillingSummary(summary).funding_error).not.toContain('trace');
expect(() => projectBillingSummary({ ...summary, recharge_products: [{ ...summary.recharge_products[0], point_amount: '600.00' }] })).toThrow();
});
it('freezes order amount, discards payment secrets and never calls a quarantined payment credited', () => {
const payload = { ...rechargeOrder({ status: 'succeeded', manual_review_required: true }), provider_payload: { codeUrl: 'weixin://wxpay/test', paySign: 'private' }, failure_reason: 'private error' };
expect(projectRechargeOrder(payload)).toMatchObject({ status: 'manual_review', qr_payload: null, amount_cents: 1000, point_amount: '500.00' });
expect(projectRechargeOrder(payload)).not.toHaveProperty('provider_payload');
expect(projectRechargeOrder(rechargeOrder(), { codeUrl: 'javascript:alert(1)' }).qr_payload).toBeNull();
});
it('preserves negative usage and held amounts in ledger history', () => {
const item = { id: 'usage', kind: 'usage', description: 'chat', status: 'settled', points: '-1.25', reserved_points: '0.00', created_at: '2026-09-22T00:00:00Z' };
expect(projectPointHistory({ items: [item], has_more: true })).toEqual({ items: [item], has_more: true });
});
it('gets the current Main credential and does not require or forward Renderer credentials', async () => {
reply(billingSummary());
const result = await call('/me');
expect(result.status).toBe(200);
expect(result.body.data).toEqual(billingSummary());
expect(net.mock.calls[0][0]).toBe('https://square.nianxx.cn/api/billing/me');
expect(net.mock.calls[0][1].headers).toEqual({ Authorization: 'Bearer main-only-secret' });
});
it('uses the same idempotency header for an explicit retry and sends only product identity', async () => {
const response = { order: rechargeOrder(), provider_payload: { codeUrl: 'weixin://wxpay/test', paySign: 'secret' } };
reply(response); reply(response);
for (let i = 0; i < 2; i++) expect((await call('/recharge/orders', 'POST', { productId: 'recharge-10', requestId: 'same-intent', ownerId: 'forged', amount: 1 })).status).toBe(200);
expect(net.mock.calls.map(([, init]) => init.headers['Idempotency-Key'])).toEqual(['makelore:same-intent', 'makelore:same-intent']);
expect(JSON.parse(net.mock.calls[0][1].body)).toEqual({ product_id: 'recharge-10' });
});
it('never issues recharge without the existing Renderer capability', async () => {
expect((await call('/recharge/orders', 'POST', { productId: 'p', requestId: 'r' }, false)).status).toBe(403);
expect(net).not.toHaveBeenCalled();
});
it('bounds page size and forwards exact offset without payer input', async () => {
reply({ items: [], has_more: false });
expect((await call('/points/transactions?offset=20&limit=999&owner=other')).status).toBe(200);
expect(net.mock.calls[0][0]).toBe('https://square.nianxx.cn/api/billing/points/transactions?limit=20&offset=20');
});
it('distinguishes definitive provider rejection from an unknown network result', async () => {
reply({ detail: { outcome: 'definitive_rejection', message: 'secret trace' } }, 502);
expect((await call('/recharge/orders', 'POST', { productId: 'p', requestId: 'r' })).body).toMatchObject({ commandOutcome: 'definitive_failure' });
net.mockRejectedValueOnce(new Error('secret trace'));
const unknown = await call('/recharge/orders', 'POST', { productId: 'p', requestId: 'r' });
expect(unknown.body.commandOutcome).toBe('unknown');
expect(JSON.stringify(unknown)).not.toContain('secret trace');
});
it('blocks account changes before dispatch and discards a late response after switch', async () => {
current.mockReturnValue(false);
expect((await call('/me')).status).toBe(401);
expect(net).not.toHaveBeenCalled();
current.mockReturnValueOnce(true).mockReturnValueOnce(false);
reply(billingSummary());
const result = await call('/me');
expect(result.status).toBe(409); expect(result.body.data).toBeUndefined();
});
it.each(['/plans', '/reset-cards', '/subscriptions', '/reset-card-orders'])('retires %s without contacting payment services', async (path) => {
expect((await call(path)).status).toBe(410); expect(net).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { projectBillingSummary, projectPointHistory, projectRechargeOrder } from '@electron/api/routes/works-billing';
import server from '../fixtures/permanent-points-server.json';
// Captured from works-square-server permanent-points API using FastAPI TestClient,
// an isolated SQLite database and a fake payment provider. All identities are synthetic.
// Covers the real API's serialized decimals, timestamps, optional fields and order envelopes.
describe('Works Square permanent-points API compatibility', () => {
it('projects old accounts at zero and genuinely registered youth at 100, without a client gift', () => {
expect(projectBillingSummary(server.before).own_points.total_remaining).toBe('0.00');
const youth = projectBillingSummary(server.youth);
expect(youth.own_points.total_remaining).toBe('100.00');
expect(youth.can_recharge).toBe(false);
});
it('recovers the same payment payload from creation, listing and individual lookup', () => {
const created = projectRechargeOrder(server.created.order, server.created.provider_payload);
expect(created.status).toBe('pending');
expect(created.qr_payload).toBe('weixin://wxpay/contract-fixture');
expect(projectRechargeOrder(server.pending)).toEqual(created);
expect(projectRechargeOrder(server.orders.items[0])).toEqual(created);
});
it('accepts the family payer’s own exact wallet even when sharing is enabled', () => {
const payer = projectBillingSummary(server.family_payer);
expect(payer.points).toMatchObject({ family_shared: true, entitlement_source: 'self', shared_available: null, total_remaining: '500.00' });
expect(payer.can_recharge).toBe(true);
});
it('honors frozen price after repricing and projects the sole confirmed credit', () => {
expect(projectBillingSummary(server.repriced).recharge_products[0].amount_cents).toBe(2000);
const paid = projectRechargeOrder(server.paid);
expect(paid).toMatchObject({ status: 'succeeded', amount_cents: 1000, point_amount: '500.00', qr_payload: null });
expect(projectBillingSummary(server.after).own_points.total_remaining).toBe('500.00');
const history = projectPointHistory(server.history);
expect(history.items).toHaveLength(1);
expect(history.items[0]).toMatchObject({ kind: 'credit', status: 'credited', points: '500.00' });
});
});

View File

@@ -592,344 +592,6 @@ describe('works square host api routes', () => {
);
});
it('loads and safely projects the V2 token point balance through the Works Square API', async () => {
const points = {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
ignored_internal_field: 'do-not-project',
};
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(points), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const handled = await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/points'),
{} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
points: {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
},
});
expect(fetchMock).toHaveBeenCalledWith(
'https://square.nianxx.cn/api/billing/points',
{
method: 'GET',
headers: {
Authorization: 'Bearer access-token',
},
},
);
});
it('rejects an invalid V2 token point balance instead of forwarding it to Renderer', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
plan_code: 'mastery',
plan_name: '精通',
weekly_remaining: '-1.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
}), { status: 200 }),
));
const response = createResponse();
const handled = await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/points'),
{} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: 'Works Square returned an invalid token point balance',
});
});
it('lists reset cards through a strict Renderer-safe projection', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
items: [
{
id: 'card-available',
status: 'available',
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
reason: 'internal operations note',
granted_by_user_id: 'private-admin-id',
},
{
id: 'card-redeemed',
status: 'redeemed',
granted_at: '2026-09-01T00:00:00Z',
expires_at: '2099-09-10T00:00:00Z',
redeemed_at: '2026-09-05T00:00:00Z',
redeemed_cycle_id: 'cycle-2',
},
{
id: 'card-stale',
status: 'available',
granted_at: '1999-12-01T00:00:00Z',
expires_at: '2000-01-01T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
},
],
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const handled = await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/reset-cards'),
{} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
cards: [
{
id: 'card-available',
status: 'available',
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
},
{
id: 'card-redeemed',
status: 'redeemed',
granted_at: '2026-09-01T00:00:00Z',
expires_at: '2099-09-10T00:00:00Z',
redeemed_at: '2026-09-05T00:00:00Z',
redeemed_cycle_id: 'cycle-2',
},
{
id: 'card-stale',
status: 'expired',
granted_at: '1999-12-01T00:00:00Z',
expires_at: '2000-01-01T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
},
],
});
expect(JSON.stringify(response.json())).not.toContain('private-admin-id');
expect(fetchMock).toHaveBeenCalledWith(
'https://square.nianxx.cn/api/billing/reset-cards',
{
method: 'GET',
headers: { Authorization: 'Bearer access-token' },
},
);
});
it('rejects an invalid reset-card list instead of partially forwarding it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
items: [{
id: 'card-invalid',
status: 'available',
granted_at: 'not-a-time',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
}],
}), { status: 200 })));
const response = createResponse();
await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/reset-cards'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: 'Works Square returned an invalid reset-card list',
});
});
it('redeems one reset card and projects only the fulfilled card contract', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
id: 'card/one',
status: 'redeemed',
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: '2026-09-09T00:00:00Z',
redeemed_cycle_id: 'cycle-new',
metadata_json: { internal: true },
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleWorksRoutes(
createRequest('POST', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/reset-cards/card%2Fone/redeem'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
card: {
id: 'card/one',
status: 'redeemed',
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: '2026-09-09T00:00:00Z',
redeemed_cycle_id: 'cycle-new',
},
});
expect(fetchMock).toHaveBeenCalledWith(
'https://square.nianxx.cn/api/billing/reset-cards/card%2Fone/redeem',
{
method: 'POST',
headers: { Authorization: 'Bearer access-token' },
},
);
});
it('maps reset-card failures to a closed safe error without upstream text', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
detail: {
error_code: 'reset_card_expired',
message: 'private upstream diagnostics must not reach Renderer',
},
}), { status: 409 })));
const response = createResponse();
await handleWorksRoutes(
createRequest('POST', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/reset-cards/card-expired/redeem'),
{} as never,
);
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({
success: false,
status: 409,
code: 'reset_card_expired',
error: '这张重置卡已过期。',
});
expect(JSON.stringify(response.json())).not.toContain('private upstream');
});
it('removes exact plan and point values from a family-shared balance', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
plan_code: 'excellence',
plan_name: '卓越',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '1000.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '875.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '925.00',
entitlement_source: 'shared_group',
family_shared: true,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: true,
}), { status: 200 }),
));
const response = createResponse();
await handleWorksRoutes(
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
response.res,
new URL('http://127.0.0.1/api/works/billing/points'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
points: {
plan_code: null,
plan_name: null,
cycle_start: null,
cycle_end: null,
next_refresh_at: null,
weekly_allowance: null,
weekly_used: null,
weekly_reserved: null,
weekly_remaining: null,
permanent_total: null,
permanent_used: null,
permanent_reserved: null,
permanent_remaining: null,
total_remaining: null,
entitlement_source: 'shared_group',
family_shared: true,
can_manage_membership: false,
upgrade_action: 'contact_family_owner',
shared_available: true,
},
});
});
it('loads the current Agent Profile through the Works Square API', async () => {
const profile = {
display_name: '小泥',

View File

@@ -1,38 +1,11 @@
import { describe, expect, it } from 'vitest';
import type { WorksTokenPointBalance } from '@/lib/works-square';
import { permanentBalance as pointBalance } from '../fixtures/permanent-points';
import {
formatWorksTokenPointValue,
isWorksTokenPointBalanceExhausted,
} from '@/lib/works-square-token-points';
function pointBalance(
overrides: Partial<WorksTokenPointBalance> = {},
): WorksTokenPointBalance {
return {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
...overrides,
};
}
describe('Works Square V2 token points', () => {
describe('permanent token points', () => {
it('formats exact server point strings without losing integer precision', () => {
expect(formatWorksTokenPointValue('1000.00')).toBe('1,000');
expect(formatWorksTokenPointValue('123456789012345678.50')).toBe('123,456,789,012,345,678.5');
@@ -46,18 +19,10 @@ describe('Works Square V2 token points', () => {
expect(isWorksTokenPointBalanceExhausted(pointBalance({ total_remaining: null }))).toBe(false);
});
it('uses the server-provided coarse availability for every non-manager', () => {
const nonManager = pointBalance({
plan_code: null,
plan_name: null,
can_manage_membership: false,
family_shared: false,
upgrade_action: 'contact_family_owner',
shared_available: false,
total_remaining: null,
});
expect(isWorksTokenPointBalanceExhausted(nonManager)).toBe(true);
expect(isWorksTokenPointBalanceExhausted({ ...nonManager, shared_available: true })).toBe(false);
expect(isWorksTokenPointBalanceExhausted({ ...nonManager, family_shared: true })).toBe(true);
it('uses exact personal balance for youth and coarse availability for shared wallets', () => {
expect(isWorksTokenPointBalanceExhausted(pointBalance({ total_remaining: '0.00', can_recharge: false }))).toBe(true);
const shared = pointBalance({ family_shared: true, entitlement_source: 'family_owner', total_remaining: null, shared_available: false, can_recharge: false });
expect(isWorksTokenPointBalanceExhausted(shared)).toBe(true);
expect(isWorksTokenPointBalanceExhausted({ ...shared, shared_available: true })).toBe(false);
});
});

View File

@@ -7,12 +7,9 @@ import {
fetchMyWorksProjects,
fetchWorksAsset,
fetchWorksAssets,
fetchWorksResetCards,
fetchWorksTokenPointBalance,
fetchWorksProjectVersions,
fetchWorksProjects,
publishWorksProjectSource,
redeemWorksResetCard,
toPlazaCard,
WorksSquareApiError,
type ProjectPublic,
@@ -214,82 +211,6 @@ describe('works square client', () => {
);
});
it('loads the V2 token point balance with the current access token', async () => {
const points = {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, points });
const result = await fetchWorksTokenPointBalance('access-token');
expect(result).toEqual(points);
expect(result.plan_code).toBe('mastery');
expect(result.plan_name).toBe('精通');
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/billing/points',
{
headers: {
'X-NianCode-Access-Token': 'access-token',
},
},
);
});
it('lists and redeems granted reset cards through Main-owned billing routes', async () => {
const availableCard = {
id: 'card one',
status: 'available' as const,
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
};
const redeemedCard = {
...availableCard,
status: 'redeemed' as const,
redeemed_at: '2026-09-09T00:00:00Z',
redeemed_cycle_id: 'cycle-new',
};
hostApiFetchMock
.mockResolvedValueOnce({ success: true, cards: [availableCard] })
.mockResolvedValueOnce({ success: true, card: redeemedCard });
await expect(fetchWorksResetCards('access-token')).resolves.toEqual([availableCard]);
await expect(redeemWorksResetCard('access-token', availableCard.id)).resolves.toEqual(redeemedCard);
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
1,
'/api/works/billing/reset-cards',
{ headers: { 'X-NianCode-Access-Token': 'access-token' } },
);
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
2,
'/api/works/billing/reset-cards/card%20one/redeem',
{
method: 'POST',
headers: { 'X-NianCode-Access-Token': 'access-token' },
},
);
});
it('loads the current user project status with versions', async () => {
const status = {
project: {