feat: add expiring reset card wallet
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user