From 62852cb62a63d9a780ef110a29ab574865a09ace Mon Sep 17 00:00:00 2001 From: lihu <495079588@qq.com> Date: Fri, 7 Aug 2026 17:35: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=E8=BE=93=E5=85=A5=E6=A1=86=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=85=A8=E9=83=A8=E6=95=B0=E5=80=BC=E6=97=B6=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E4=B8=BA=E6=98=BE=E7=A4=BA=E4=B8=BA0=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/__tests__/quota-input.test.tsx | 220 ++++++++++++++++++ .../components/redemptions-mutate-drawer.tsx | 4 +- 2 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 web/src/features/redemption-codes/components/__tests__/quota-input.test.tsx diff --git a/web/src/features/redemption-codes/components/__tests__/quota-input.test.tsx b/web/src/features/redemption-codes/components/__tests__/quota-input.test.tsx new file mode 100644 index 000000000000..d32d4bdf6df2 --- /dev/null +++ b/web/src/features/redemption-codes/components/__tests__/quota-input.test.tsx @@ -0,0 +1,220 @@ +/* +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 { after, afterEach, describe, test } from 'node:test' + +import { Window } from 'happy-dom' + +const domWindow = new Window() +const domGlobals = [ + 'window', + 'document', + 'navigator', + 'HTMLElement', + 'HTMLButtonElement', + 'HTMLInputElement', + 'HTMLFormElement', + 'SVGElement', + 'Node', + 'Element', + 'Event', + '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 { createInstance } = await import('i18next') +const { I18nextProvider, initReactI18next } = await import('react-i18next') +const { api } = await import('@/lib/api') +const { RedemptionsProvider } = await import('../redemptions-provider') +const { RedemptionsMutateDrawer } = await import('../redemptions-mutate-drawer') + +const i18n = createInstance() +await i18n.use(initReactI18next).init({ + lng: 'en', + resources: { en: { translation: {} } }, +}) + +const reactTestGlobals = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} +reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true + +type ApiMethod = (url: string) => Promise<{ data: unknown }> +type MockableApi = { + get: ApiMethod +} +type RenderedDrawer = { + host: HTMLDivElement + root: ReturnType +} + +const apiClient = api as unknown as MockableApi +const originalGet = apiClient.get +let renderedDrawer: RenderedDrawer | null = null + +const currentRow = { + id: 1, + user_id: 0, + name: 'existing', + key: 'code', + status: 1, + quota: 5_000_000, + created_time: 0, + redeemed_time: 0, + expired_time: 0, + used_user_id: 0, +} + +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 getQuotaInput(): HTMLInputElement { + const label = [...document.querySelectorAll('label')].find( + (candidate) => candidate.textContent?.includes('Quota') + ) + assert.ok(label, 'Expected quota label') + const input = label + .closest('[data-slot="form-item"]') + ?.querySelector('input[type="number"]') + assert.ok(input, 'Expected quota input') + return input +} + +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 renderDrawer(isUpdate: boolean): Promise { + apiClient.get = async (url) => { + assert.equal(url, `/api/redemption/${currentRow.id}`) + return { data: { success: true, data: currentRow } } + } + + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + renderedDrawer = { host, root } + + await act(async () => + root.render( + + + undefined} + currentRow={isUpdate ? currentRow : undefined} + /> + + + ) + ) + + if (isUpdate) { + await act(async () => + waitForCondition( + () => getQuotaInput().value === '10', + 'Redemption data did not finish loading' + ) + ) + } + + return getQuotaInput() +} + +afterEach(async () => { + apiClient.get = originalGet + if (renderedDrawer) { + await act(async () => renderedDrawer?.root.unmount()) + renderedDrawer.host.remove() + renderedDrawer = null + } + document.body.replaceChildren() +}) + +after(() => { + domWindow.close() +}) + +describe('Redemption quota input', () => { + test('allows clearing the quota while creating a redemption code', async () => { + const input = await renderDrawer(false) + + await changeInput(input, '') + + assert.equal(input.value, '') + }) + + test('allows clearing the quota while updating a redemption code', async () => { + const input = await renderDrawer(true) + + await changeInput(input, '') + + assert.equal(input.value, '') + }) +}) 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 b8f455e6381c..8e5c5b198822 100644 --- a/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -300,7 +300,9 @@ export function RedemptionsMutateDrawer({ placeholder={quotaPlaceholder} onChange={(e) => field.onChange( - Number.parseFloat(e.target.value) || 0 + e.target.value === '' + ? '' + : Number.parseFloat(e.target.value) ) } />