feat: add expiring reset card wallet

This commit is contained in:
2026-09-08 23:04:54 +08:00
parent 91ae7912ab
commit 01525834fd
8 changed files with 943 additions and 5 deletions

View File

@@ -0,0 +1,123 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test.describe('Account reset-card wallet', () => {
test('redeems an available card and refreshes the authoritative point balance', async ({ launchElectronApp }) => {
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')).toContainText('已使用');
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

@@ -2,9 +2,11 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '@/components/layout/Sidebar';
import type { WorksTokenPointBalance } from '@/lib/works-square';
import type { WorksResetCard, WorksTokenPointBalance } from '@/lib/works-square';
const fetchTokenPointBalanceMock = vi.hoisted(() => vi.fn());
const fetchResetCardsMock = vi.hoisted(() => vi.fn());
const redeemResetCardMock = vi.hoisted(() => vi.fn());
const getValidAccessTokenMock = vi.hoisted(() => vi.fn());
const authState = vi.hoisted(() => ({
user: {
@@ -36,6 +38,8 @@ vi.mock('react-i18next', () => ({
}));
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),
@@ -103,6 +107,18 @@ function pointBalance(
};
}
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']}>
@@ -115,9 +131,26 @@ async function renderUsageDrawer(): Promise<HTMLElement> {
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', () => {
beforeEach(() => {
getValidAccessTokenMock.mockReset();
fetchTokenPointBalanceMock.mockReset();
fetchResetCardsMock.mockReset();
redeemResetCardMock.mockReset();
getValidAccessTokenMock.mockResolvedValue('access-token');
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
fetchResetCardsMock.mockResolvedValue([]);
});
it('shows the authoritative weekly and total point balances without the retired rolling rows', async () => {
@@ -207,4 +240,95 @@ describe('Sidebar V2 token point balance', () => {
expect(drawer).not.toHaveTextContent('总可用');
expect(screen.queryByTestId('sidebar-account-upgrade-button')).not.toBeInTheDocument();
});
it('shows available, expired, and redeemed reset cards with explicit dates', 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(drawer).toHaveTextContent('已使用');
expect(drawer).toHaveTextContent('有效期至 2099-09-15');
expect(drawer).toHaveTextContent('使用于 2026-09-09');
expect(screen.getByTestId('sidebar-reset-card-redeem-card-1')).toHaveTextContent('立即使用');
expect(screen.queryByTestId('sidebar-reset-card-redeem-card-expired')).not.toBeInTheDocument();
});
it('redeems one 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(within(drawer).getByText('已使用')).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();
});
});

View File

@@ -694,6 +694,182 @@ describe('works square host api routes', () => {
});
});
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({

View File

@@ -7,10 +7,12 @@ import {
fetchMyWorksProjects,
fetchWorksAsset,
fetchWorksAssets,
fetchWorksResetCards,
fetchWorksTokenPointBalance,
fetchWorksProjectVersions,
fetchWorksProjects,
publishWorksProjectSource,
redeemWorksResetCard,
toPlazaCard,
WorksSquareApiError,
type ProjectPublic,
@@ -251,6 +253,43 @@ describe('works square client', () => {
);
});
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: {