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)
)
}
/>