Files
makelore/tests/unit/works-billing-routes.test.ts

108 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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