108 lines
7.7 KiB
TypeScript
108 lines
7.7 KiB
TypeScript
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();
|
||
});
|
||
});
|