From e926e5cacee22fc838d94e8b95b438e825508e11 Mon Sep 17 00:00:00 2001 From: lihu-001 Date: Fri, 7 Aug 2026 17:06:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E7=A0=81=E9=A2=9D=E5=BA=A6=E7=B2=BE=E5=BA=A6=E6=8D=9F=E5=A4=B1?= =?UTF-8?q?=20(#6685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复兑换码额度精度损失(#6680) * fix(redemption): guard update data integrity --- .../redemptions-mutate-drawer.test.tsx | 439 ++++++++++++++++++ .../components/redemptions-mutate-drawer.tsx | 356 ++++++++------ .../redemption-codes/lib/redemption-form.ts | 9 +- web/src/lib/currency.ts | 26 +- web/src/lib/format.ts | 37 +- 5 files changed, 721 insertions(+), 146 deletions(-) create mode 100644 web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx diff --git a/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx b/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx new file mode 100644 index 00000000..46008e10 --- /dev/null +++ b/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx @@ -0,0 +1,439 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' + +import { Window } from 'happy-dom' + +import type { Redemption } from '../../types' + +// Use Bun's runner at runtime while reusing the Node test types installed here. +const bunTestModule = 'bun:test' +const { afterAll, afterEach, test } = (await import(bunTestModule)) as { + afterAll: typeof import('node:test').after + afterEach: typeof import('node:test').afterEach + test: typeof import('node:test').test +} + +const domWindow = new Window() +const domGlobals = [ + 'window', + 'document', + 'navigator', + 'HTMLElement', + 'HTMLButtonElement', + 'HTMLInputElement', + 'HTMLFormElement', + 'HTMLLabelElement', + 'HTMLFieldSetElement', + 'SVGElement', + 'Node', + 'Element', + 'Event', + 'KeyboardEvent', + 'PointerEvent', + 'MouseEvent', + 'FocusEvent', + 'CustomEvent', + 'MutationObserver', + 'ResizeObserver', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'getComputedStyle', +] as const + +for (const key of domGlobals) { + Object.defineProperty(globalThis, key, { + configurable: true, + value: domWindow[key], + }) +} + +const { act } = await import('react') +const { createRoot } = await import('react-dom/client') +const i18n = (await import('i18next')).default +const { I18nextProvider, initReactI18next } = await import('react-i18next') +const { Toaster, toast } = await import('sonner') +const { api } = await import('@/lib/api') +const { useSystemConfigStore } = await import('@/stores/system-config-store') +const { RedemptionsProvider } = await import('../redemptions-provider') +const { RedemptionsMutateDrawer } = await import('../redemptions-mutate-drawer') + +await i18n.use(initReactI18next).init({ + lng: 'en', + resources: { + en: { + translation: { + 'Failed to load': 'Failed to load', + 'Loading...': 'Loading...', + 'Save changes': 'Save changes', + 'Something went wrong!': 'Something went wrong!', + }, + }, + }, +}) + +const reactTestGlobals = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} +reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true + +type ApiMethod = (url: string, data?: unknown) => Promise<{ data: unknown }> +type MockableApi = { + get: ApiMethod + put: ApiMethod +} +type RenderedDrawer = { + host: HTMLDivElement + root: ReturnType +} +type CurrencyFixture = { + quotaDisplayType: 'USD' | 'CNY' + usdExchangeRate: number +} + +const apiClient = api as unknown as MockableApi +const originalGet = apiClient.get +const originalPut = apiClient.put +const originalConsoleLog = Reflect.get(console, 'log') +let renderedDrawer: RenderedDrawer | null = null + +function redemption(id: number, quota = 500001): Redemption { + return { + id, + user_id: 1, + name: `code-${id}`, + key: `key-${id}`, + status: 1, + quota, + created_time: 1, + redeemed_time: 0, + expired_time: 0, + used_user_id: 0, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } +} + +function drawerTree(currentRow: Redemption) { + return ( + + + undefined} + /> + + + + ) +} + +async function renderDrawer( + currentRow: Redemption, + currency: CurrencyFixture = { + quotaDisplayType: 'USD', + usdExchangeRate: 1, + } +): Promise { + useSystemConfigStore.getState().setConfig({ + currency: { + displayInCurrency: true, + quotaDisplayType: currency.quotaDisplayType, + quotaPerUnit: 500000, + usdExchangeRate: currency.usdExchangeRate, + customCurrencySymbol: '¤', + customCurrencyExchangeRate: 1, + }, + }) + + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + renderedDrawer = { host, root } + + await act(async () => root.render(drawerTree(currentRow))) +} + +async function rerenderDrawer(currentRow: Redemption): Promise { + assert.ok(renderedDrawer) + await act(async () => renderedDrawer?.root.render(drawerTree(currentRow))) +} + +async function waitForCondition( + condition: () => boolean, + failureMessage: string +): Promise { + if (condition()) return + + await new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + if (!condition()) return + clearTimeout(timeoutId) + observer.disconnect() + resolve() + }) + const timeoutId = setTimeout(() => { + observer.disconnect() + reject(new Error(`${failureMessage}: ${document.body.textContent}`)) + }, 1500) + + observer.observe(document, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }) + }) +} + +function getSaveButton(): HTMLButtonElement { + const button = document.querySelector( + 'button[form="redemption-form"][type="submit"]' + ) + assert.ok(button) + return button +} + +function getControlByLabel(labelText: string): T { + const label = [...document.querySelectorAll('label')].find( + (candidate) => candidate.textContent?.trim() === labelText + ) + assert.ok(label, `Expected label "${labelText}"`) + assert.ok(label.htmlFor) + const control = + label.control ?? + label + .closest('[data-slot="form-item"]') + ?.querySelector('[data-slot="form-control"], input') + assert.ok(control) + return control as T +} + +async function changeInput(input: HTMLInputElement, value: string) { + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + domWindow.HTMLInputElement.prototype, + 'value' + )?.set + assert.ok(valueSetter) + valueSetter.call(input, value) + input.dispatchEvent( + new domWindow.Event('input', { bubbles: true }) as unknown as Event + ) + }) +} + +async function submitForm(): Promise { + const form = document.querySelector('#redemption-form') + assert.ok(form) + await act(async () => + form.dispatchEvent( + new domWindow.Event('submit', { + bubbles: true, + cancelable: true, + }) as unknown as Event + ) + ) +} + +async function waitForLoadedForm(): Promise { + await act(async () => + waitForCondition(() => { + const saveButton = getSaveButton() + return ( + saveButton.textContent?.includes('Save changes') === true && + !saveButton.disabled + ) + }, 'redemption drawer did not finish loading') + ) +} + +afterEach(async () => { + apiClient.get = originalGet + apiClient.put = originalPut + Reflect.set(console, 'log', originalConsoleLog) + toast.dismiss() + domWindow.localStorage.clear() + if (renderedDrawer) { + await act(async () => renderedDrawer?.root.unmount()) + renderedDrawer.host.remove() + renderedDrawer = null + } + document.body.replaceChildren() +}) + +afterAll(() => { + domWindow.close() +}) + +test('redemption drawer shows the reported CNY quota without floating-point noise', async () => { + const original = redemption(1, 13888889) + apiClient.get = async () => ({ data: { success: true, data: original } }) + + await renderDrawer(original, { + quotaDisplayType: 'CNY', + usdExchangeRate: 7.2, + }) + await waitForLoadedForm() + + assert.equal(getControlByLabel('Quota (CNY)').value, '200') +}) + +test('redemption drawer blocks updates and reports an error when loading rejects', async () => { + const updates: unknown[] = [] + Reflect.set(console, 'log', () => undefined) + apiClient.get = async () => { + throw new Error('network failure') + } + apiClient.put = async (_url, data) => { + updates.push(data) + return { data: { success: true } } + } + + await renderDrawer(redemption(1)) + await act(async () => + waitForCondition( + () => + document.body.textContent?.includes('Something went wrong!') === true, + 'load error toast was not shown' + ) + ) + + assert.equal(getSaveButton().disabled, true) + await submitForm() + assert.deepEqual(updates, []) +}) + +test('redemption drawer blocks updates and uses localized feedback for unsuccessful responses', async () => { + apiClient.get = async () => ({ + data: { success: false, message: 'raw server message' }, + }) + + await renderDrawer(redemption(1)) + await act(async () => + waitForCondition( + () => document.body.textContent?.includes('Failed to load') === true, + 'unsuccessful-load toast was not shown' + ) + ) + + assert.equal(getSaveButton().disabled, true) + assert.equal(document.body.textContent?.includes('raw server message'), false) +}) + +test('redemption drawer keeps the original quota when another field changes', async () => { + const original = redemption(1) + const updates: Array> = [] + apiClient.get = async () => ({ data: { success: true, data: original } }) + apiClient.put = async (_url, data) => { + assert.ok(data && typeof data === 'object') + updates.push(data as Record) + return { data: { success: true, data: original } } + } + + await renderDrawer(original) + await waitForLoadedForm() + assert.equal(getControlByLabel('Quota (USD)').value, '1') + + await changeInput(getControlByLabel('Name'), 'renamed') + await submitForm() + await act(async () => + waitForCondition(() => updates.length === 1, 'update was not submitted') + ) + + assert.equal(updates[0]?.name, 'renamed') + assert.equal(updates[0]?.quota, 500001) +}) + +test('redemption drawer recalculates quota when the quota field changes', async () => { + const original = redemption(1) + const updates: Array> = [] + apiClient.get = async () => ({ data: { success: true, data: original } }) + apiClient.put = async (_url, data) => { + assert.ok(data && typeof data === 'object') + updates.push(data as Record) + return { data: { success: true, data: original } } + } + + await renderDrawer(original) + await waitForLoadedForm() + await changeInput(getControlByLabel('Quota (USD)'), '2') + await submitForm() + await act(async () => + waitForCondition(() => updates.length === 1, 'update was not submitted') + ) + + assert.equal(updates[0]?.quota, 1000000) +}) + +test('redemption drawer ignores an older response after switching records', async () => { + const first = redemption(1, 500001) + const second = redemption(2, 1000001) + const firstRequest = deferred<{ data: unknown }>() + const secondRequest = deferred<{ data: unknown }>() + const requestedUrls: string[] = [] + const updates: Array> = [] + apiClient.get = (url) => { + requestedUrls.push(url) + if (url === '/api/redemption/1') return firstRequest.promise + if (url === '/api/redemption/2') return secondRequest.promise + throw new Error(`Unexpected GET ${url}`) + } + apiClient.put = async (_url, data) => { + assert.ok(data && typeof data === 'object') + updates.push(data as Record) + return { data: { success: true, data: second } } + } + + await renderDrawer(first) + await rerenderDrawer(second) + await act(async () => + waitForCondition( + () => requestedUrls.includes('/api/redemption/2'), + 'second redemption was not requested' + ) + ) + await act(async () => + secondRequest.resolve({ data: { success: true, data: second } }) + ) + await waitForLoadedForm() + + await act(async () => + firstRequest.resolve({ data: { success: true, data: first } }) + ) + assert.equal(getControlByLabel('Name').value, 'code-2') + + await changeInput(getControlByLabel('Name'), 'second') + await submitForm() + await act(async () => + waitForCondition(() => updates.length === 1, 'update was not submitted') + ) + + assert.equal(updates[0]?.id, 2) + assert.equal(updates[0]?.quota, 1000001) +}) diff --git a/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index 47f7a387..b8f455e6 100644 --- a/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -51,7 +51,12 @@ import { SheetTitle, } from '@/components/ui/sheet' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' -import { formatQuota, parseQuotaFromDollars } from '@/lib/format' +import { + formatQuota, + getEditableQuotaStep, + parseQuotaFromDollars, +} from '@/lib/format' +import { handleServerError } from '@/lib/handle-server-error' import { addTimeToDate } from '@/lib/time' import { createRedemption, updateRedemption, getRedemption } from '../api' @@ -63,7 +68,7 @@ import { transformFormDataToPayload, transformRedemptionToFormDefaults, } from '../lib' -import { type Redemption } from '../types' +import type { Redemption } from '../types' import { useRedemptions } from './redemptions-provider' type RedemptionsMutateDrawerProps = { @@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({ }: RedemptionsMutateDrawerProps) { const { t } = useTranslation() const isUpdate = !!currentRow + const redemptionId = currentRow?.id const { triggerRefresh } = useRedemptions() const [isSubmitting, setIsSubmitting] = useState(false) + const [redemptionLoadState, setRedemptionLoadState] = useState< + 'idle' | 'loading' | 'ready' | 'error' + >('idle') + const [loadedRedemption, setLoadedRedemption] = useState( + null + ) const form = useForm({ resolver: zodResolver(getRedemptionFormSchema(t)), @@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({ // Load existing data when updating useEffect(() => { - if (open && isUpdate && currentRow) { - // For update, fetch fresh data - getRedemption(currentRow.id).then((result) => { - if (result.success && result.data) { - form.reset(transformRedemptionToFormDefaults(result.data)) - } - }) - } else if (open && !isUpdate) { - // For create, reset to defaults - form.reset(REDEMPTION_FORM_DEFAULT_VALUES) + if (!open) { + setRedemptionLoadState('idle') + setLoadedRedemption(null) + return } - }, [open, isUpdate, currentRow, form]) + + if (!isUpdate || redemptionId === undefined) { + form.reset(REDEMPTION_FORM_DEFAULT_VALUES) + setRedemptionLoadState('ready') + setLoadedRedemption(null) + return + } + + let ignoreResult = false + + form.reset(REDEMPTION_FORM_DEFAULT_VALUES) + setRedemptionLoadState('loading') + setLoadedRedemption(null) + + void getRedemption(redemptionId) + .then((result) => { + if (ignoreResult) return + + if ( + !result.success || + !result.data || + result.data.id !== redemptionId + ) { + setRedemptionLoadState('error') + toast.error(t('Failed to load')) + return + } + + form.reset(transformRedemptionToFormDefaults(result.data)) + setLoadedRedemption(result.data) + setRedemptionLoadState('ready') + }) + .catch((error: unknown) => { + if (ignoreResult) return + + setRedemptionLoadState('error') + handleServerError(error) + }) + + return () => { + ignoreResult = true + } + }, [open, isUpdate, redemptionId, form, t]) + + const isUpdateReady = + !isUpdate || + (redemptionLoadState === 'ready' && loadedRedemption?.id === redemptionId) + const isLoadingRedemption = redemptionLoadState === 'loading' const onSubmit = async (data: RedemptionFormValues) => { + if (isUpdate && (!currentRow || !loadedRedemption || !isUpdateReady)) { + return + } + setIsSubmitting(true) try { const basePayload = transformFormDataToPayload(data) - if (isUpdate && currentRow) { + if (isUpdate && currentRow && loadedRedemption) { + const quota = form.getFieldState('quota_dollars').isDirty + ? basePayload.quota + : loadedRedemption.quota const result = await updateRedemption({ ...basePayload, + quota, id: currentRow.id, }) if (result.success) { @@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({ const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() const tokensOnly = currencyMeta.kind === 'tokens' + const quotaStep = getEditableQuotaStep() const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaPlaceholder = tokensOnly ? t('Enter quota in tokens') : t('Enter quota in {{currency}}', { currency: currencyLabel }) + let submitButtonLabel = t('Save changes') + if (isLoadingRedemption) { + submitButtonLabel = t('Loading...') + } else if (isSubmitting) { + submitButtonLabel = t('Saving...') + } return ( - - ( - - {t('Name')} - - - - - {t('Name for this redemption code (1-20 characters)')} - - - - )} - /> - - ( - - {quotaLabel} - - - field.onChange(parseFloat(e.target.value) || 0) - } - /> - - - {tokensOnly - ? t('Enter the quota amount in tokens') - : t('Enter the quota amount in {{currency}}', { - currency: currencyLabel, - })} - - - - )} - /> - - ( - - {t('Expiration Time')} -
- - - -
- - - - -
-
- - {t('Leave empty for never expires')} - - -
- )} - /> - - {!isUpdate && ( +
+ ( - {t('Quantity')} + {t('Name')} - - field.onChange(parseInt(e.target.value, 10) || 1) - } - /> + - {t('Create multiple redemption codes at once (1-100)')} + {t('Name for this redemption code (1-20 characters)')} )} /> - )} - + + ( + + {quotaLabel} + + + field.onChange( + Number.parseFloat(e.target.value) || 0 + ) + } + /> + + + {tokensOnly + ? t('Enter the quota amount in tokens') + : t('Enter the quota amount in {{currency}}', { + currency: currencyLabel, + })} + + + + )} + /> + + ( + + {t('Expiration Time')} +
+ + + +
+ + + + +
+
+ + {t('Leave empty for never expires')} + + +
+ )} + /> + + {!isUpdate && ( + ( + + {t('Quantity')} + + + field.onChange( + Number.parseInt(e.target.value, 10) || 1 + ) + } + /> + + + {t( + 'Create multiple redemption codes at once (1-100)' + )} + + + + )} + /> + )} + +
}> {t('Close')} - diff --git a/web/src/features/redemption-codes/lib/redemption-form.ts b/web/src/features/redemption-codes/lib/redemption-form.ts index fa4c3059..a8aa9d1a 100644 --- a/web/src/features/redemption-codes/lib/redemption-form.ts +++ b/web/src/features/redemption-codes/lib/redemption-form.ts @@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com import type { TFunction } from 'i18next' import { z } from 'zod' -import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format' +import { + parseQuotaFromDollars, + quotaUnitsToEditableAmount, +} from '@/lib/format' import { REDEMPTION_VALIDATION, getRedemptionFormErrorMessages, } from '../constants' -import { type RedemptionFormData, type Redemption } from '../types' +import type { RedemptionFormData, Redemption } from '../types' // ============================================================================ // Form Schema (use getRedemptionFormSchema(t) in components for i18n messages) @@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults( ): RedemptionFormValues { return { name: redemption.name, - quota_dollars: quotaUnitsToDollars(redemption.quota), + quota_dollars: quotaUnitsToEditableAmount(redemption.quota), expired_time: redemption.expired_time > 0 ? new Date(redemption.expired_time * 1000) diff --git a/web/src/lib/currency.ts b/web/src/lib/currency.ts index ae572961..15a3d345 100644 --- a/web/src/lib/currency.ts +++ b/web/src/lib/currency.ts @@ -244,6 +244,23 @@ function mergeOptions( } } +function getFractionDigits( + value: number, + digitsLarge: number, + digitsSmall: number +): number { + return Math.abs(value) >= 1 ? digitsLarge : digitsSmall +} + +/** Return the configured fraction digits for a plain currency value. */ +export function getCurrencyFractionDigits( + value: number, + options?: CurrencyFormatOptions +): number { + const merged = mergeOptions(options) + return getFractionDigits(value, merged.digitsLarge, merged.digitsSmall) +} + function removeTrailingZeros(str: string): string { if (!str.includes('.')) return str return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '') @@ -261,7 +278,7 @@ function formatNumberWithSuffix( return `${removeTrailingZeros(result.toFixed(1))}k` } - const digits = abs >= 1 ? digitsLarge : digitsSmall + const digits = getFractionDigits(value, digitsLarge, digitsSmall) return removeTrailingZeros(value.toFixed(digits)) } @@ -300,8 +317,11 @@ function formatCurrencyValue( ) } - const digits = - Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall + const digits = getFractionDigits( + value, + options.digitsLarge, + options.digitsSmall + ) const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero) if (meta.kind === 'currency') { diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 64aa7a2d..83829fc3 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -22,6 +22,7 @@ import { formatCurrencyFromUSD, formatQuotaWithCurrency, getCurrencyDisplay, + getCurrencyFractionDigits, } from './currency' // ============================================================================ @@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number { */ export function quotaUnitsToDollars(units: number): number { const { config, meta } = getCurrencyDisplay() + return quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta) +} +function quotaUnitsToDisplayAmount( + units: number, + quotaPerUnit: number, + meta: ReturnType['meta'] +): number { if (meta.kind === 'tokens') { return units } - const usdAmount = units / config.quotaPerUnit - const exchangeRate = - meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1 + return (units / quotaPerUnit) * meta.exchangeRate +} - return usdAmount * exchangeRate +/** + * Convert quota units to a plain number suitable for an editable input. + * Uses the same precision as quota list formatting without symbols or suffixes. + */ +export function quotaUnitsToEditableAmount(units: number): number { + const { config, meta } = getCurrencyDisplay() + const amount = quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta) + + if (meta.kind === 'tokens') { + return Math.round(amount) + } + + return Number(amount.toFixed(getCurrencyFractionDigits(amount))) +} + +/** Return the input step matching the configured editable quota precision. */ +export function getEditableQuotaStep(): number { + const { meta } = getCurrencyDisplay() + if (meta.kind === 'tokens') { + return 1 + } + + return 10 ** -getCurrencyFractionDigits(0) } // ============================================================================