feat: replace desktop membership with permanent point wallet

This commit is contained in:
brother7 committed 2026-09-22 11:43:04 +08:00
1 parent 2f82b9f79c
commit a1cce428af
24 files changed
+1343 -1707

No files matched your search

+107
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();
});
});