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
deleted file mode 100644
index 46008e102aa4..000000000000
--- a/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx
+++ /dev/null
@@ -1,439 +0,0 @@
-/*
-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 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)
)
}
/>
diff --git a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx
index fc6294f6ce4f..b3d2833083c6 100644
--- a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx
+++ b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx
@@ -17,11 +17,18 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
-import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
import type React from 'react'
+// Use Bun's runner at runtime while reusing the Node test types installed here.
+const bunTestModule = 'bun:test'
+const { afterAll, describe, test } = (await import(bunTestModule)) as {
+ afterAll: typeof import('node:test').after
+ describe: typeof import('node:test').describe
+ test: typeof import('node:test').test
+}
+
const domWindow = new Window()
const domGlobals = [
'window',
@@ -105,7 +112,7 @@ function normalizedText(value: string | null): string {
}
describe('log cost display', () => {
- after(() => {
+ afterAll(() => {
domWindow.close()
})