feat: add expiring reset card wallet
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# Task: Integrate expiring reset-card wallet into Makelore desktop
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260908-reset-card-wallet-client-6c3e8a1f
|
||||
- Mode: Feature
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\OthersProjects\makelore
|
||||
- Base commit: 91ae7912ab15559c7fe8859974bfe1da9e4e7451
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Add Electron Main-owned Works Square proxy routes for listing and redeeming expiring reset-card grants.
|
||||
- Strictly project the public reset-card DTO before it reaches Renderer and preserve only a closed set of redemption error codes.
|
||||
- Add a reset-card wallet to the existing account menu with available, expired, and redeemed states, explicit expiry, and manual redemption.
|
||||
- Refresh both the reset-card wallet and authoritative Token Point V2 balance after successful redemption.
|
||||
- Add focused Main route, Renderer API, component, and user-visible interaction coverage.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Keep Works credentials and upstream response shaping behind the existing Host API/Main route boundary; do not add direct Renderer-to-Works requests or new IPC contracts.
|
||||
- Treat Works Square `/api/billing/reset-cards` and `/api/billing/reset-cards/{card_id}/redeem` as the authority; the client must not calculate reset allowances or mutate card state optimistically.
|
||||
- Operations-granted cards are manually redeemed inventory with an explicit expiry. Existing paid reset-card immediate fulfilment is unchanged and is not represented as desktop inventory.
|
||||
- Allow a self-owned youth entitlement to redeem its card, but disable redemption while the current balance is family-shared; the server remains the final ownership authority.
|
||||
- Derive an available card as expired when its `expires_at` is no longer in the future so stale UI cannot invite a known-invalid redemption.
|
||||
- A successful or idempotently replayed redemption must refresh the card and balance authorities. Definitive failures must remain retryable by the user without inventing a new mutation identity or local success.
|
||||
- Use the existing Makelore light visual system, compact account-menu disclosure, tabular dates, specific transitions, and at least 40px redemption targets.
|
||||
- Preserve and do not commit the three adopted foreign task records from 20260901-20260902.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added Main-owned reset-card list/redeem routes that authenticate upstream with the existing Works token, strictly project the public card DTO, derive elapsed available cards as expired, and expose only a closed set of safe redemption errors to Renderer.
|
||||
- Added typed Renderer helpers for the Host API routes without introducing direct Renderer-to-Works requests or a new IPC contract.
|
||||
- Added an account-menu reset-card wallet with lazy loading, available-card count, explicit local-time expiry, available/expired/redeemed states, retry handling, and a 40px manual redemption action.
|
||||
- Kept operations-granted cards separate from the existing Token Point balance rows and existing immediate paid reset-card flow.
|
||||
- Prevented known-expired or family-shared-wallet redemptions in the client while retaining the server as the final authority; successful server fulfillment refreshes both the authoritative card list and Token Point V2 balance.
|
||||
- Added Main route, Renderer helper, component, and Electron interaction coverage, including strict projection, safe error mapping, local expiry, shared-wallet behavior, single redemption, and post-redemption balance refresh.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm exec vitest run tests/unit/works-routes.test.ts tests/unit/works-square.test.ts tests/unit/sidebar-token-points.test.tsx --maxWorkers=2` — 3 files / 78 tests passed.
|
||||
- `pnpm exec eslint electron/api/routes/works.ts src/lib/works-square.ts src/components/layout/Sidebar.tsx tests/unit/works-routes.test.ts tests/unit/works-square.test.ts tests/unit/sidebar-token-points.test.tsx tests/e2e/reset-card-wallet.spec.ts` — passed with no warnings or errors.
|
||||
- `pnpm run typecheck` — passed.
|
||||
- `pnpm run lint:check` — passed with 0 errors and the repository's 5 existing warnings in unchanged Home/Makelore files.
|
||||
- `pnpm run build:vite` — Renderer, Main, Preload, and utility production builds passed; only the repository's existing Browserslist/import/chunk-size warnings were emitted.
|
||||
- `pnpm exec playwright test tests/e2e/reset-card-wallet.spec.ts --repeat-each=3` — 3/3 Electron runs passed.
|
||||
- `pnpm test` — 224 files / 1,952 tests passed and 2 skipped before the existing Pi real-process cold timing assertion measured 2,124ms against its 2,000ms threshold; the same file then passed 6/6 in isolation with one worker, and the separately invoked pressure test passed 1/1. This recurring unrelated timing shape is already recorded in prior project tasks and does not intersect the reset-card paths.
|
||||
- `git diff --check` — passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Deploy the Works Square reset-card backend and apply Alembic revision `20260908_reset_card_grants_0087` before shipping or enabling this client wallet against that environment.
|
||||
- Rebuild/release Makelore together with the compatible server version, then smoke-test one Operations grant, one desktop redemption, and the resulting authoritative weekly balance refresh.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- During Integration, update the canonical desktop billing data-flow/current-state documentation with the Main-owned reset-card proxy boundary and account-menu wallet behavior.
|
||||
@@ -81,6 +81,13 @@ const TOKEN_POINT_METADATA_FIELDS = [
|
||||
const TOKEN_POINT_ENTITLEMENT_SOURCES = new Set(['self', 'family_owner', 'shared_group']);
|
||||
const TOKEN_POINT_UPGRADE_ACTIONS = new Set(['self_service', 'contact_family_owner']);
|
||||
const TOKEN_POINT_VALUE_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u;
|
||||
const RESET_CARD_STATUSES = new Set(['available', 'expired', 'redeemed']);
|
||||
const RESET_CARD_ERROR_MESSAGES: Record<string, string> = {
|
||||
reset_card_expired: '这张重置卡已过期。',
|
||||
token_point_wallet_owner_required: '当前额度由家庭管理员管理,无法使用这张重置卡。',
|
||||
billing_temporarily_paused: '计费服务正在切换,请稍后再试。',
|
||||
token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。',
|
||||
};
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -168,7 +175,11 @@ function getErrorMessage(payload: unknown, fallback: string): string {
|
||||
function getPayloadErrorCode(payload: unknown): string | undefined {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const detail = isRecord(payload.detail) ? payload.detail : undefined;
|
||||
return readOptionalString(payload.code) ?? (detail ? readOptionalString(detail.code) : undefined);
|
||||
return readOptionalString(payload.code)
|
||||
?? readOptionalString(payload.error_code)
|
||||
?? (detail
|
||||
? readOptionalString(detail.code) ?? readOptionalString(detail.error_code)
|
||||
: undefined);
|
||||
}
|
||||
|
||||
function stripReadOnlyAgentProfileFields(body: AgentProfileUpdateInput): AgentProfileUpdateInput {
|
||||
@@ -190,6 +201,31 @@ async function sendUpstreamError(
|
||||
});
|
||||
}
|
||||
|
||||
async function sendResetCardUpstreamError(
|
||||
res: ServerResponse,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const payload = await readResponsePayload(response);
|
||||
const upstreamCode = getPayloadErrorCode(payload);
|
||||
const code = upstreamCode && RESET_CARD_ERROR_MESSAGES[upstreamCode]
|
||||
? upstreamCode
|
||||
: undefined;
|
||||
const status = response.status >= 400 && response.status < 500 ? response.status : 502;
|
||||
const error = code
|
||||
? RESET_CARD_ERROR_MESSAGES[code]
|
||||
: response.status === 404
|
||||
? '重置卡不存在或不可用。'
|
||||
: response.status === 409
|
||||
? '当前有待处理的计费操作,暂时不能使用重置卡。'
|
||||
: '重置卡服务暂时不可用,请稍后重试。';
|
||||
sendJson(res, status, {
|
||||
success: false,
|
||||
status: response.status,
|
||||
...(code ? { code } : {}),
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
function sendPublishSourceFailure(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
@@ -344,6 +380,56 @@ function projectSafeTokenPointBalance(value: unknown): Record<string, unknown> |
|
||||
};
|
||||
}
|
||||
|
||||
function readResetCardTimestamp(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const normalized = value.trim();
|
||||
return Number.isNaN(Date.parse(normalized)) ? null : normalized;
|
||||
}
|
||||
|
||||
function projectSafeResetCard(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = readOptionalString(value.id);
|
||||
const status = readOptionalString(value.status);
|
||||
const grantedAt = readResetCardTimestamp(value.granted_at);
|
||||
const expiresAt = readResetCardTimestamp(value.expires_at);
|
||||
const redeemedAt = value.redeemed_at === null
|
||||
? null
|
||||
: readResetCardTimestamp(value.redeemed_at);
|
||||
const redeemedCycleId = readNullableStringField(value, 'redeemed_cycle_id');
|
||||
if (
|
||||
!id
|
||||
|| !status
|
||||
|| !RESET_CARD_STATUSES.has(status)
|
||||
|| !grantedAt
|
||||
|| !expiresAt
|
||||
|| redeemedAt === null && value.redeemed_at !== null
|
||||
|| redeemedCycleId === undefined
|
||||
) return null;
|
||||
|
||||
if (status === 'redeemed') {
|
||||
if (!redeemedAt || !redeemedCycleId) return null;
|
||||
} else if (redeemedAt !== null || redeemedCycleId !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
status: status === 'available' && Date.parse(expiresAt) <= Date.now() ? 'expired' : status,
|
||||
granted_at: grantedAt,
|
||||
expires_at: expiresAt,
|
||||
redeemed_at: redeemedAt,
|
||||
redeemed_cycle_id: redeemedCycleId,
|
||||
};
|
||||
}
|
||||
|
||||
function projectSafeResetCardPage(value: unknown): Record<string, unknown>[] | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.items)) return null;
|
||||
const cards = value.items.map(projectSafeResetCard);
|
||||
return cards.some((card) => card === null)
|
||||
? null
|
||||
: cards as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function projectSafeProject(
|
||||
value: unknown,
|
||||
options: { requireStatus?: boolean } = {},
|
||||
@@ -716,6 +802,60 @@ async function handleGetBillingTokenPoints(
|
||||
sendJson(res, response.status, { success: true, points });
|
||||
}
|
||||
|
||||
async function handleListBillingResetCards(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
||||
const response = await proxyAwareFetch(createWorksUrl('/api/billing/reset-cards').toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await sendResetCardUpstreamError(res, response);
|
||||
return;
|
||||
}
|
||||
|
||||
const cards = projectSafeResetCardPage(await readResponsePayload(response));
|
||||
if (!cards) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid reset-card list' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, response.status, { success: true, cards });
|
||||
}
|
||||
|
||||
async function handleRedeemBillingResetCard(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
cardId: string,
|
||||
): Promise<void> {
|
||||
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
||||
const response = await proxyAwareFetch(
|
||||
createWorksUrl(`/api/billing/reset-cards/${encodeURIComponent(readRequiredString(cardId, 'card_id'))}/redeem`).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await sendResetCardUpstreamError(res, response);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = projectSafeResetCard(await readResponsePayload(response));
|
||||
if (!card) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid reset-card result' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, response.status, { success: true, card });
|
||||
}
|
||||
|
||||
async function handleAgentProfile(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -1500,6 +1640,17 @@ export async function handleWorksRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/works/billing/reset-cards' && req.method === 'GET') {
|
||||
await handleListBillingResetCards(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
const resetCardRedeemMatch = url.pathname.match(/^\/api\/works\/billing\/reset-cards\/([^/]+)\/redeem$/);
|
||||
if (resetCardRedeemMatch && req.method === 'POST') {
|
||||
await handleRedeemBillingResetCard(req, res, decodeURIComponent(resetCardRedeemMatch[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/works/ai-gateway/images/generations' && req.method === 'POST') {
|
||||
await handleSubmitImageGeneration(req, res);
|
||||
return true;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LogOut,
|
||||
Plus,
|
||||
Settings as SettingsIcon,
|
||||
Ticket,
|
||||
UserCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,7 +35,14 @@ import {
|
||||
subscribeCodingProjectCreationRequest,
|
||||
subscribeCodingProjectOpenRequest,
|
||||
} from '@/lib/coding-project-entry';
|
||||
import { fetchWorksTokenPointBalance, type WorksTokenPointBalance } from '@/lib/works-square';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import {
|
||||
fetchWorksResetCards,
|
||||
fetchWorksTokenPointBalance,
|
||||
redeemWorksResetCard,
|
||||
type WorksResetCard,
|
||||
type WorksTokenPointBalance,
|
||||
} from '@/lib/works-square';
|
||||
import {
|
||||
formatWorksTokenPointValue,
|
||||
isWorksTokenPointBalanceExhausted,
|
||||
@@ -67,6 +75,11 @@ type TokenPointState = {
|
||||
balance: WorksTokenPointBalance | null;
|
||||
};
|
||||
|
||||
type ResetCardState = {
|
||||
status: 'idle' | 'loading' | 'loaded' | 'error';
|
||||
cards: WorksResetCard[];
|
||||
};
|
||||
|
||||
type ProjectEntryError = {
|
||||
project: CodingProjectSummary;
|
||||
message: string;
|
||||
@@ -96,6 +109,34 @@ function getRefreshTimeLabel(value: string | null | undefined): string | null {
|
||||
return formatted ? `刷新时间 ${formatted}` : null;
|
||||
}
|
||||
|
||||
function formatResetCardTime(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return `${date.getFullYear()}-${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function getEffectiveResetCardStatus(card: WorksResetCard): WorksResetCard['status'] {
|
||||
if (card.status === 'available' && Date.parse(card.expires_at) <= Date.now()) return 'expired';
|
||||
return card.status;
|
||||
}
|
||||
|
||||
function getResetCardErrorMessage(error: unknown): string {
|
||||
const details = error instanceof AppError ? error.details : undefined;
|
||||
const backendCode = typeof details?.backendCode === 'string' ? details.backendCode : null;
|
||||
const status = typeof details?.status === 'number' ? details.status : null;
|
||||
const messages: Record<string, string> = {
|
||||
reset_card_expired: '这张重置卡已过期,卡包已刷新。',
|
||||
token_point_wallet_owner_required: '当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。',
|
||||
billing_temporarily_paused: '计费服务正在切换,请稍后再试。',
|
||||
token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。',
|
||||
};
|
||||
if (backendCode && messages[backendCode]) return messages[backendCode];
|
||||
if (status === 404) return '重置卡不存在或已不可用。';
|
||||
if (status === 409) return '当前有待处理的计费操作,暂时不能使用重置卡。';
|
||||
return '重置卡暂时无法使用,请稍后重试。';
|
||||
}
|
||||
|
||||
function isTokenPointBalanceExhausted(state: TokenPointState): boolean {
|
||||
if (state.status !== 'loaded' || !state.balance) return false;
|
||||
return isWorksTokenPointBalanceExhausted(state.balance);
|
||||
@@ -191,7 +232,10 @@ export function Sidebar({
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [usageDrawerOpen, setUsageDrawerOpen] = useState(false);
|
||||
const [resetCardDrawerOpen, setResetCardDrawerOpen] = useState(false);
|
||||
const [tokenPointState, setTokenPointState] = useState<TokenPointState>({ status: 'idle', balance: null });
|
||||
const [resetCardState, setResetCardState] = useState<ResetCardState>({ status: 'idle', cards: [] });
|
||||
const [redeemingResetCardId, setRedeemingResetCardId] = useState<string | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
|
||||
@@ -206,6 +250,7 @@ export function Sidebar({
|
||||
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const accountMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const tokenPointRequestIdRef = useRef(0);
|
||||
const resetCardRequestIdRef = useRef(0);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
@@ -227,6 +272,10 @@ export function Sidebar({
|
||||
tokenPointBalance && !tokenPointBalance.can_manage_membership,
|
||||
);
|
||||
const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at);
|
||||
const availableResetCardCount = resetCardState.cards.filter(
|
||||
(card) => getEffectiveResetCardStatus(card) === 'available',
|
||||
).length;
|
||||
const resetCardRedemptionBlocked = tokenPointBalance?.entitlement_source === 'shared_group';
|
||||
const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null;
|
||||
|
||||
const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => {
|
||||
@@ -334,6 +383,7 @@ export function Sidebar({
|
||||
useEffect(() => {
|
||||
if (!accountMenuOpen) {
|
||||
setUsageDrawerOpen(false);
|
||||
setResetCardDrawerOpen(false);
|
||||
}
|
||||
}, [accountMenuOpen]);
|
||||
|
||||
@@ -366,19 +416,51 @@ export function Sidebar({
|
||||
});
|
||||
}, [authUser, getValidAccessToken]);
|
||||
|
||||
const refreshResetCards = useCallback(async (): Promise<void> => {
|
||||
if (!authUser) {
|
||||
resetCardRequestIdRef.current += 1;
|
||||
setResetCardState({ status: 'idle', cards: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = resetCardRequestIdRef.current + 1;
|
||||
resetCardRequestIdRef.current = requestId;
|
||||
setResetCardState((current) => ({ status: 'loading', cards: current.cards }));
|
||||
try {
|
||||
const accessToken = await getValidAccessToken();
|
||||
if (!accessToken) throw new Error('请先登录后再查看重置卡');
|
||||
const cards = await fetchWorksResetCards(accessToken);
|
||||
if (resetCardRequestIdRef.current === requestId) {
|
||||
setResetCardState({ status: 'loaded', cards });
|
||||
}
|
||||
} catch {
|
||||
if (resetCardRequestIdRef.current === requestId) {
|
||||
setResetCardState((current) => ({ status: 'error', cards: current.cards }));
|
||||
}
|
||||
}
|
||||
}, [authUser, getValidAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTokenPoints();
|
||||
}, [refreshTokenPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
resetCardRequestIdRef.current += 1;
|
||||
setResetCardState({ status: 'idle', cards: [] });
|
||||
setRedeemingResetCardId(null);
|
||||
}, [authUser?.userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authUser) return undefined;
|
||||
|
||||
const handleFocus = () => {
|
||||
refreshTokenPoints();
|
||||
if (resetCardDrawerOpen) void refreshResetCards();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshTokenPoints();
|
||||
if (resetCardDrawerOpen) void refreshResetCards();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -388,7 +470,7 @@ export function Sidebar({
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authUser, refreshTokenPoints]);
|
||||
}, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser && accountMenuOpen) {
|
||||
@@ -396,6 +478,12 @@ export function Sidebar({
|
||||
}
|
||||
}, [accountMenuOpen, authUser, refreshTokenPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser && resetCardDrawerOpen) {
|
||||
void refreshResetCards();
|
||||
}
|
||||
}, [authUser, refreshResetCards, resetCardDrawerOpen]);
|
||||
|
||||
|
||||
const openCreateProject = useCallback(() => {
|
||||
setNewProjectName('');
|
||||
@@ -512,12 +600,14 @@ export function Sidebar({
|
||||
const navigateFromAccountMenu = (path: string) => {
|
||||
setAccountMenuOpen(false);
|
||||
setUsageDrawerOpen(false);
|
||||
setResetCardDrawerOpen(false);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const openProfileDialog = () => {
|
||||
setAccountMenuOpen(false);
|
||||
setUsageDrawerOpen(false);
|
||||
setResetCardDrawerOpen(false);
|
||||
setProfileDialogOpen(true);
|
||||
void syncProfileNow().catch(() => undefined);
|
||||
};
|
||||
@@ -526,9 +616,44 @@ export function Sidebar({
|
||||
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleRedeemResetCard = async (card: WorksResetCard) => {
|
||||
if (redeemingResetCardId) return;
|
||||
if (getEffectiveResetCardStatus(card) !== 'available') {
|
||||
toast.error('这张重置卡已过期。');
|
||||
void refreshResetCards();
|
||||
return;
|
||||
}
|
||||
if (resetCardRedemptionBlocked) {
|
||||
toast.error('当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。');
|
||||
return;
|
||||
}
|
||||
|
||||
setRedeemingResetCardId(card.id);
|
||||
try {
|
||||
const accessToken = await getValidAccessToken();
|
||||
if (!accessToken) throw new Error('请先登录后再使用重置卡');
|
||||
const redeemedCard = await redeemWorksResetCard(accessToken, card.id);
|
||||
setResetCardState((current) => ({
|
||||
status: 'loaded',
|
||||
cards: current.cards.map((item) => item.id === redeemedCard.id ? redeemedCard : item),
|
||||
}));
|
||||
toast.success('重置卡已使用,本周额度已刷新。');
|
||||
refreshTokenPoints();
|
||||
void refreshResetCards();
|
||||
} catch (error) {
|
||||
toast.error(getResetCardErrorMessage(error));
|
||||
void refreshResetCards();
|
||||
} finally {
|
||||
setRedeemingResetCardId((current) => current === card.id ? null : current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountLogout = async () => {
|
||||
setAccountMenuOpen(false);
|
||||
setUsageDrawerOpen(false);
|
||||
setResetCardDrawerOpen(false);
|
||||
resetCardRequestIdRef.current += 1;
|
||||
setResetCardState({ status: 'idle', cards: [] });
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
@@ -1021,7 +1146,10 @@ export function Sidebar({
|
||||
aria-expanded={usageDrawerOpen}
|
||||
aria-controls="sidebar-account-usage-drawer"
|
||||
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
|
||||
onClick={() => setUsageDrawerOpen((open) => !open)}
|
||||
onClick={() => {
|
||||
setUsageDrawerOpen((open) => !open);
|
||||
setResetCardDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">剩余用量</span>
|
||||
@@ -1113,6 +1241,107 @@ export function Sidebar({
|
||||
</div>
|
||||
) : null}
|
||||
</DisclosureContent>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="sidebar-reset-cards-menuitem"
|
||||
aria-expanded={resetCardDrawerOpen}
|
||||
aria-controls="sidebar-reset-cards-drawer"
|
||||
className="motion-press flex min-h-10 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-[background-color,transform] duration-100 hover:bg-surface-subtle"
|
||||
onClick={() => {
|
||||
setResetCardDrawerOpen((open) => !open);
|
||||
setUsageDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
<Ticket className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">重置卡</span>
|
||||
{availableResetCardCount > 0 ? (
|
||||
<span
|
||||
data-testid="sidebar-reset-card-available-count"
|
||||
className="min-w-5 rounded-full bg-accent-soft px-1.5 py-0.5 text-center text-[10px] font-semibold tabular-nums text-accent-strong"
|
||||
>
|
||||
{availableResetCardCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', resetCardDrawerOpen && 'rotate-90')} />
|
||||
</button>
|
||||
<DisclosureContent
|
||||
open={resetCardDrawerOpen}
|
||||
id="sidebar-reset-cards-drawer"
|
||||
data-testid="sidebar-reset-cards-drawer"
|
||||
className="ml-5 rounded-lg bg-surface-subtle/70 p-1 text-xs font-medium text-muted-foreground shadow-soft"
|
||||
innerClassName="grid gap-1"
|
||||
>
|
||||
{resetCardState.status === 'loading' && resetCardState.cards.length === 0 ? (
|
||||
<p className="px-1.5 py-2 text-pretty text-[11px]">正在加载重置卡…</p>
|
||||
) : null}
|
||||
{resetCardState.status === 'error' ? (
|
||||
<div data-testid="sidebar-reset-cards-error" className="rounded-md bg-background px-1.5 py-1.5 shadow-soft">
|
||||
<p className="text-pretty text-[11px] leading-4">重置卡暂时无法获取。</p>
|
||||
<button
|
||||
type="button"
|
||||
className="motion-press mt-1 min-h-10 text-[11px] font-semibold text-foreground underline underline-offset-2 transition-transform duration-100"
|
||||
onClick={() => void refreshResetCards()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{resetCardState.status === 'loaded' && resetCardState.cards.length === 0 ? (
|
||||
<p className="px-1.5 py-2 text-pretty text-[11px]">暂无赠送的重置卡。</p>
|
||||
) : null}
|
||||
{resetCardRedemptionBlocked && resetCardState.cards.length > 0 ? (
|
||||
<p className="rounded-md bg-background px-1.5 py-1.5 text-pretty text-[10px] leading-4 shadow-soft">
|
||||
当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。
|
||||
</p>
|
||||
) : null}
|
||||
{resetCardState.cards.length > 0 ? (
|
||||
<div className="grid max-h-64 gap-1 overflow-y-auto pr-0.5">
|
||||
{resetCardState.cards.map((card) => {
|
||||
const status = getEffectiveResetCardStatus(card);
|
||||
const expiresAt = formatResetCardTime(card.expires_at) ?? '时间不可用';
|
||||
const redeemedAt = formatResetCardTime(card.redeemed_at);
|
||||
const redeeming = redeemingResetCardId === card.id;
|
||||
return (
|
||||
<article
|
||||
key={card.id}
|
||||
data-testid={`sidebar-reset-card-${card.id}`}
|
||||
className="rounded-lg bg-background p-2 shadow-soft"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-foreground">额度重置卡</span>
|
||||
<span className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
|
||||
status === 'available' && 'bg-accent-soft text-accent-strong',
|
||||
status === 'expired' && 'bg-destructive/10 text-destructive',
|
||||
status === 'redeemed' && 'bg-surface-subtle text-muted-foreground',
|
||||
)}>
|
||||
{status === 'available' ? '可使用' : status === 'expired' ? '已过期' : '已使用'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-pretty text-[10px] leading-4 tabular-nums">
|
||||
有效期至 {expiresAt}
|
||||
</p>
|
||||
{status === 'redeemed' && redeemedAt ? (
|
||||
<p className="text-pretty text-[10px] leading-4 tabular-nums">使用于 {redeemedAt}</p>
|
||||
) : null}
|
||||
{status === 'available' ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`sidebar-reset-card-redeem-${card.id}`}
|
||||
className="motion-press mt-1.5 min-h-10 w-full rounded-md bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-[background-color,transform] duration-100 hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={Boolean(redeemingResetCardId) || resetCardRedemptionBlocked}
|
||||
onClick={() => void handleRedeemResetCard(card)}
|
||||
>
|
||||
{redeeming ? '使用中…' : resetCardRedemptionBlocked ? '家庭共享中不可用' : '立即使用'}
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</DisclosureContent>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -209,6 +209,15 @@ export type WorksTokenPointBalance = {
|
||||
shared_available: boolean | null;
|
||||
};
|
||||
|
||||
export type WorksResetCard = {
|
||||
id: string;
|
||||
status: 'available' | 'expired' | 'redeemed';
|
||||
granted_at: string;
|
||||
expires_at: string;
|
||||
redeemed_at: string | null;
|
||||
redeemed_cycle_id: string | null;
|
||||
};
|
||||
|
||||
export type PlazaCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -427,6 +436,34 @@ export async function fetchWorksTokenPointBalance(accessToken: string): Promise<
|
||||
return assertSuccess(response, 'points', 'Failed to load Works Square token points');
|
||||
}
|
||||
|
||||
export async function fetchWorksResetCards(accessToken: string): Promise<WorksResetCard[]> {
|
||||
const response = await hostApiFetch<WorksActionResponse<'cards', WorksResetCard[]>>(
|
||||
'/api/works/billing/reset-cards',
|
||||
{
|
||||
headers: {
|
||||
'X-NianCode-Access-Token': accessToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
return assertSuccess(response, 'cards', 'Failed to load Works Square reset cards');
|
||||
}
|
||||
|
||||
export async function redeemWorksResetCard(
|
||||
accessToken: string,
|
||||
cardId: string,
|
||||
): Promise<WorksResetCard> {
|
||||
const response = await hostApiFetch<WorksActionResponse<'card', WorksResetCard>>(
|
||||
`/api/works/billing/reset-cards/${encodeURIComponent(cardId)}/redeem`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-NianCode-Access-Token': accessToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
return assertSuccess(response, 'card', 'Failed to redeem Works Square reset card');
|
||||
}
|
||||
|
||||
export async function fetchMyWorksProjectStatus(
|
||||
accessToken: string,
|
||||
appId: string,
|
||||
|
||||
123
tests/e2e/reset-card-wallet.spec.ts
Normal file
123
tests/e2e/reset-card-wallet.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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