mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: separate storage code revoke and delete
* feat(quota-store): separate revoke and delete semantics for redemption codes
- PATCH /api/admin/quota-store/storage-codes/:code accepts { revokedAt } to
revoke a code (keeps it visible, prevents future redemptions)
- DELETE /api/admin/quota-store/storage-codes/:code removes eligible codes
- Added revokeStorageCodeSchema (shared) with zValidator on PATCH route
- Fixed revokeStorageRedemptionCode API fn to use PATCH with revokedAt payload
- Added deleteStorageRedemptionCode API fn using DELETE
- UI: separate Revoke and Delete buttons per code row with distinct disabled rules
- Revoke disabled when already revoked or fully redeemed
- Delete disabled when any redemptions exist (usesCount > 0)
- Updated confirmation copy to describe product outcome, not technical action
- i18n: added delete keys in en.json and zh.json; updated revokeConfirm copy
- Updated integration, unit, and component tests
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: align storage code revoke contract
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -849,21 +849,21 @@ describe('Quota Store API', () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ code: 'ZS-GEN-1', state: 'revoked' }),
|
||||
json: async () => ({}),
|
||||
} as Response)
|
||||
|
||||
const res = await app.request('/api/admin/quota-store/storage-codes/ZS-GEN-1', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: 'revoked' }),
|
||||
body: JSON.stringify({ revoked: true }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({ code: 'ZS-GEN-1', state: 'revoked' })
|
||||
await expect(res.json()).resolves.toEqual({ code: 'ZS-GEN-1', revoked: true })
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit]
|
||||
expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}/api/store/storage-codes/ZS-GEN-1`)
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ state: 'revoked' })
|
||||
expect(JSON.parse(init.body as string)).toEqual({ revoked: true })
|
||||
})
|
||||
|
||||
it('rejects non-admin storage code management', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
quotaStorePackagePatchSchema,
|
||||
quotaStoreSettingsSchema,
|
||||
redemptionInputSchema,
|
||||
revokeStorageCodeSchema,
|
||||
} from '@shared/schemas'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
@@ -122,16 +123,17 @@ const adminQuotaStore = new Hono<Env>()
|
||||
if ('error' in result) return c.json(result, 502)
|
||||
return c.json({ items: result, total: result.length }, 201)
|
||||
})
|
||||
.patch('/storage-codes/:code', async (c) => {
|
||||
.patch('/storage-codes/:code', zValidator('json', revokeStorageCodeSchema), async (c) => {
|
||||
const code = c.req.param('code')
|
||||
const result = await requestCloudWithBinding(
|
||||
c,
|
||||
`${storageCodesPath()}/${encodeURIComponent(c.req.param('code'))}`,
|
||||
`${storageCodesPath()}/${encodeURIComponent(code)}`,
|
||||
'PATCH',
|
||||
z.object({}).passthrough(),
|
||||
await c.req.json(),
|
||||
c.req.valid('json'),
|
||||
)
|
||||
if ('error' in result) return c.json(result, 502)
|
||||
return c.json(result)
|
||||
return c.json({ code, revoked: true })
|
||||
})
|
||||
.delete('/storage-codes/:code', async (c) => {
|
||||
const result = await deleteCloud(c, `${storageCodesPath()}/${encodeURIComponent(c.req.param('code'))}`)
|
||||
|
||||
@@ -24,6 +24,7 @@ export type {
|
||||
QuotaStorePackagePatchInput,
|
||||
QuotaStoreSettingsInput,
|
||||
RedemptionInput,
|
||||
RevokeStorageCodeInput,
|
||||
StorageCodeStatus,
|
||||
} from './quota-store'
|
||||
export {
|
||||
@@ -34,6 +35,7 @@ export {
|
||||
quotaStorePackagePatchSchema,
|
||||
quotaStoreSettingsSchema,
|
||||
redemptionInputSchema,
|
||||
revokeStorageCodeSchema,
|
||||
storageCodeStatusSchema,
|
||||
} from './quota-store'
|
||||
export type { CreateShareInput, CreateShareRequest, ShareKind } from './share'
|
||||
|
||||
@@ -44,6 +44,10 @@ export const generateStorageCodesInputSchema = z.object({
|
||||
count: z.number().int().min(1).max(100),
|
||||
})
|
||||
|
||||
export const revokeStorageCodeSchema = z.object({
|
||||
revoked: z.literal(true),
|
||||
})
|
||||
|
||||
export const cloudDeliveryEventSchema = z
|
||||
.object({
|
||||
eventId: z.string().min(1),
|
||||
@@ -87,4 +91,5 @@ export type CheckoutInput = z.infer<typeof checkoutInputSchema>
|
||||
export type RedemptionInput = z.infer<typeof redemptionInputSchema>
|
||||
export type StorageCodeStatus = z.infer<typeof storageCodeStatusSchema>
|
||||
export type GenerateStorageCodesInput = z.input<typeof generateStorageCodesInputSchema>
|
||||
export type RevokeStorageCodeInput = z.infer<typeof revokeStorageCodeSchema>
|
||||
export type CloudDeliveryEvent = z.infer<typeof cloudDeliveryEventSchema>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
createQuotaStorePackage,
|
||||
deleteQuotaStorePackage,
|
||||
deleteStorageRedemptionCode,
|
||||
generateStorageRedemptionCodes,
|
||||
revokeStorageRedemptionCode,
|
||||
updateQuotaStorePackage,
|
||||
@@ -74,9 +75,11 @@ export function usePackageEditor() {
|
||||
export function useCodeActions() {
|
||||
const [form, setForm] = useState(emptyCodeForm)
|
||||
const [revokingCode, setRevokingCode] = useState<string | null>(null)
|
||||
const [deletingCode, setDeletingCode] = useState<string | null>(null)
|
||||
const generate = useGenerateCodesMutation(form, () => setForm(emptyCodeForm))
|
||||
const revoke = useRevokeCodeMutation(setRevokingCode)
|
||||
return { form, setForm, revokingCode, generate, revoke }
|
||||
const deleteCode = useDeleteCodeMutation(setDeletingCode)
|
||||
return { form, setForm, revokingCode, deletingCode, generate, revoke, deleteCode }
|
||||
}
|
||||
|
||||
export function PackagesTab({
|
||||
@@ -198,10 +201,12 @@ export function CodesTab({
|
||||
available={available}
|
||||
pending={actions.generate.isPending}
|
||||
revokingCode={actions.revokingCode}
|
||||
deletingCode={actions.deletingCode}
|
||||
onStatusChange={onStatusChange}
|
||||
onFormChange={actions.setForm}
|
||||
onGenerate={() => actions.generate.mutate()}
|
||||
onRevoke={(code) => actions.revoke.mutate(code)}
|
||||
onDelete={(code) => actions.deleteCode.mutate(code)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -276,6 +281,21 @@ function useRevokeCodeMutation(setRevokingCode: (code: string | null) => void) {
|
||||
})
|
||||
}
|
||||
|
||||
function useDeleteCodeMutation(setDeletingCode: (code: string | null) => void) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: deleteStorageRedemptionCode,
|
||||
onMutate: (code) => setDeletingCode(code),
|
||||
onSettled: () => setDeletingCode(null),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storage-plans', 'storage-codes'] })
|
||||
toast.success(t('admin.storagePlans.codes.deleted'))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
}
|
||||
|
||||
function editPackage(
|
||||
pkg: QuotaStorePackage,
|
||||
setEditing: (pkg: QuotaStorePackage) => void,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GenerateStorageCodesInput, StorageCodeStatus } from '@shared/schemas'
|
||||
import type { StorageRedemptionCode } from '@shared/types'
|
||||
import { Ban, Plus } from 'lucide-react'
|
||||
import { Ban, Plus, Trash2 } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -233,8 +233,10 @@ type CodeListProps = {
|
||||
status: StorageCodeStatus | 'all'
|
||||
available: boolean
|
||||
revokingCode: string | null
|
||||
deletingCode: string | null
|
||||
onStatusChange: (status: StorageCodeStatus | 'all') => void
|
||||
onRevoke: (code: string) => void
|
||||
onDelete: (code: string) => void
|
||||
}
|
||||
|
||||
function CodeStatusSelect({ status, onStatusChange }: Pick<CodeListProps, 'status' | 'onStatusChange'>) {
|
||||
@@ -278,10 +280,21 @@ function CodeTable(props: CodeListProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & { code: StorageRedemptionCode }) {
|
||||
function CodeRow({
|
||||
code,
|
||||
available,
|
||||
revokingCode,
|
||||
deletingCode,
|
||||
onRevoke,
|
||||
onDelete,
|
||||
}: CodeListProps & { code: StorageRedemptionCode }) {
|
||||
const { t } = useTranslation()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const revoked = Boolean(code.revokedAt)
|
||||
const redeemed = code.usesCount >= code.maxUses
|
||||
const canRevoke = !revoked && !redeemed
|
||||
const canDelete = code.usesCount === 0
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs">{code.code}</TableCell>
|
||||
@@ -294,23 +307,38 @@ function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & {
|
||||
<CodeStatusBadge code={code} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!available || revoked || revokingCode === code.code}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
{t('admin.storagePlans.codes.revoke')}
|
||||
</Button>
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!available || !canRevoke || revokingCode === code.code}
|
||||
onClick={() => setRevokeConfirmOpen(true)}
|
||||
>
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
{t('admin.storagePlans.codes.revoke')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!available || !canDelete || deletingCode === code.code}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('admin.storagePlans.codes.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={revokeConfirmOpen} onOpenChange={setRevokeConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.storagePlans.codes.revokeTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.storagePlans.codes.revokeConfirm', { code: code.code })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={revokingCode === code.code} onClick={() => setConfirmOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={revokingCode === code.code}
|
||||
onClick={() => setRevokeConfirmOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -318,7 +346,7 @@ function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & {
|
||||
disabled={revokingCode === code.code}
|
||||
onClick={() => {
|
||||
onRevoke(code.code)
|
||||
setConfirmOpen(false)
|
||||
setRevokeConfirmOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('admin.storagePlans.codes.revoke')}
|
||||
@@ -326,6 +354,33 @@ function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.storagePlans.codes.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.storagePlans.codes.deleteConfirm', { code: code.code })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={deletingCode === code.code}
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deletingCode === code.code}
|
||||
onClick={() => {
|
||||
onDelete(code.code)
|
||||
setDeleteConfirmOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('admin.storagePlans.codes.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@@ -1087,7 +1087,11 @@
|
||||
"admin.storagePlans.codes.revoked": "Code revoked",
|
||||
"admin.storagePlans.codes.revoke": "Revoke",
|
||||
"admin.storagePlans.codes.revokeTitle": "Revoke code",
|
||||
"admin.storagePlans.codes.revokeConfirm": "Revoke {{code}}? Users will no longer be able to redeem this code. Storage already granted by earlier redemptions will not be changed.",
|
||||
"admin.storagePlans.codes.revokeConfirm": "Revoke {{code}}? This code will stop working for future redemptions. Existing redeemed storage or traffic will not be changed.",
|
||||
"admin.storagePlans.codes.deleted": "Code deleted",
|
||||
"admin.storagePlans.codes.delete": "Delete",
|
||||
"admin.storagePlans.codes.deleteTitle": "Delete code",
|
||||
"admin.storagePlans.codes.deleteConfirm": "Delete {{code}}? This code will be removed from the list. Use this only for codes that should no longer appear in management.",
|
||||
"admin.storagePlans.codes.code": "Code",
|
||||
"admin.storagePlans.codes.storage": "Storage",
|
||||
"admin.storagePlans.codes.uses": "Uses",
|
||||
|
||||
@@ -1087,7 +1087,11 @@
|
||||
"admin.storagePlans.codes.revoked": "兑换码已撤销",
|
||||
"admin.storagePlans.codes.revoke": "撤销",
|
||||
"admin.storagePlans.codes.revokeTitle": "撤销兑换码",
|
||||
"admin.storagePlans.codes.revokeConfirm": "确定撤销 {{code}} 吗?撤销后用户将无法再使用这个兑换码,之前已经兑换成功的存储空间不会受到影响。",
|
||||
"admin.storagePlans.codes.revokeConfirm": "确定撤销 {{code}} 吗?此兑换码将无法再用于新的兑换。之前已兑换的存储或流量不会受到影响。",
|
||||
"admin.storagePlans.codes.deleted": "兑换码已删除",
|
||||
"admin.storagePlans.codes.delete": "删除",
|
||||
"admin.storagePlans.codes.deleteTitle": "删除兑换码",
|
||||
"admin.storagePlans.codes.deleteConfirm": "确定删除 {{code}} 吗?此兑换码将从列表中移除。仅对不再需要出现在管理界面中的兑换码使用此操作。",
|
||||
"admin.storagePlans.codes.code": "兑换码",
|
||||
"admin.storagePlans.codes.storage": "容量",
|
||||
"admin.storagePlans.codes.uses": "使用",
|
||||
|
||||
+9
-2
@@ -30,6 +30,7 @@ import {
|
||||
deleteQuotaStorePackage,
|
||||
deleteShare,
|
||||
deleteStorage,
|
||||
deleteStorageRedemptionCode,
|
||||
deleteTeamLogo,
|
||||
deleteUser,
|
||||
disconnectCloud,
|
||||
@@ -370,12 +371,14 @@ describe('api', () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
||||
.mockResolvedValueOnce(makeResponse({ items: [{ code: 'ZS123' }], total: 1 }))
|
||||
.mockResolvedValueOnce(makeResponse({ code: 'ZS123', revoked: true }))
|
||||
.mockResolvedValueOnce(makeResponse({ code: 'ZS123', deleted: true }))
|
||||
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
||||
|
||||
await listStorageRedemptionCodes('active')
|
||||
await generateStorageRedemptionCodes({ resourceType: 'traffic', resourceBytes: 1024, maxUses: 2, count: 3 })
|
||||
await revokeStorageRedemptionCode('ZS123')
|
||||
await deleteStorageRedemptionCode('ZS123')
|
||||
await listAdminQuotaDeliveryRecords()
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
|
||||
@@ -390,8 +393,11 @@ describe('api', () => {
|
||||
count: 3,
|
||||
})
|
||||
expect(calls[2][0]).toBe('/api/admin/quota-store/storage-codes/ZS123')
|
||||
expect(calls[2][1].method).toBe('DELETE')
|
||||
expect(calls[3][0]).toBe('/api/admin/quota-store/delivery-records')
|
||||
expect(calls[2][1].method).toBe('PATCH')
|
||||
expect(JSON.parse(calls[2][1].body as string)).toEqual({ revoked: true })
|
||||
expect(calls[3][0]).toBe('/api/admin/quota-store/storage-codes/ZS123')
|
||||
expect(calls[3][1].method).toBe('DELETE')
|
||||
expect(calls[4][0]).toBe('/api/admin/quota-store/delivery-records')
|
||||
})
|
||||
|
||||
it('calls user store endpoints', async () => {
|
||||
@@ -459,6 +465,7 @@ describe('api', () => {
|
||||
() => generateStorageRedemptionCodes({ resourceType: 'storage', resourceBytes: 1024, count: 1 }),
|
||||
],
|
||||
['revokeStorageRedemptionCode', () => revokeStorageRedemptionCode('ZS123')],
|
||||
['deleteStorageRedemptionCode', () => deleteStorageRedemptionCode('ZS123')],
|
||||
['listAdminQuotaDeliveryRecords', () => listAdminQuotaDeliveryRecords()],
|
||||
['listPurchasableQuotaPackages', () => listPurchasableQuotaPackages()],
|
||||
['listQuotaStoreTargets', () => listQuotaStoreTargets()],
|
||||
|
||||
@@ -343,6 +343,15 @@ export function generateStorageRedemptionCodes(data: GenerateStorageCodesInput)
|
||||
}
|
||||
|
||||
export function revokeStorageRedemptionCode(code: string) {
|
||||
return unwrap<{ code: string; revoked: true }>(
|
||||
adminQuotaStoreApi['storage-codes'][':code'].$patch({
|
||||
param: { code },
|
||||
json: { revoked: true },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteStorageRedemptionCode(code: string) {
|
||||
return unwrap<{ code: string; deleted: boolean }>(
|
||||
adminQuotaStoreApi['storage-codes'][':code'].$delete({ param: { code } }),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ApiError,
|
||||
createQuotaStorePackage,
|
||||
deleteQuotaStorePackage,
|
||||
deleteStorageRedemptionCode,
|
||||
generateStorageRedemptionCodes,
|
||||
getQuotaStoreSettings,
|
||||
listAdminQuotaDeliveryRecords,
|
||||
@@ -57,6 +58,7 @@ vi.mock('@/lib/api', () => {
|
||||
ApiError: MockApiError,
|
||||
createQuotaStorePackage: vi.fn(),
|
||||
deleteQuotaStorePackage: vi.fn(),
|
||||
deleteStorageRedemptionCode: vi.fn(),
|
||||
generateStorageRedemptionCodes: vi.fn(),
|
||||
getQuotaStoreSettings: vi.fn(),
|
||||
listAdminQuotaDeliveryRecords: vi.fn(),
|
||||
@@ -385,7 +387,7 @@ describe('AdminStoragePlansPage', () => {
|
||||
total: 1,
|
||||
})
|
||||
vi.mocked(generateStorageRedemptionCodes).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(revokeStorageRedemptionCode).mockResolvedValue({ code: 'ZS-CODE-1', deleted: true })
|
||||
vi.mocked(revokeStorageRedemptionCode).mockResolvedValue({ code: 'ZS-CODE-1', revoked: true })
|
||||
|
||||
const view = renderAdminPage()
|
||||
|
||||
@@ -416,6 +418,42 @@ describe('AdminStoragePlansPage', () => {
|
||||
await waitFor(() => expect(revokeStorageRedemptionCode).toHaveBeenCalledWith('ZS-CODE-1', expect.anything()))
|
||||
})
|
||||
|
||||
it('deletes an eligible storage redemption code from the codes tab', async () => {
|
||||
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
|
||||
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listStorageRedemptionCodes).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
code: 'ZS-CODE-1',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 107374182400,
|
||||
maxUses: 1,
|
||||
usesCount: 0,
|
||||
expiresAt: null,
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
vi.mocked(deleteStorageRedemptionCode).mockResolvedValue({ code: 'ZS-CODE-1', deleted: true })
|
||||
|
||||
const view = renderAdminPage()
|
||||
|
||||
await waitFor(() => expect(view.getByRole('tab', { name: 'admin.storagePlans.tabs.codes' })).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('tab', { name: 'admin.storagePlans.tabs.codes' }))
|
||||
await waitFor(() => expect(view.getByText('ZS-CODE-1')).toBeTruthy())
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'admin.storagePlans.codes.delete' }))
|
||||
expect(deleteStorageRedemptionCode).not.toHaveBeenCalled()
|
||||
|
||||
const deleteDialog = await view.findByRole('dialog')
|
||||
expect(within(deleteDialog).getByText('admin.storagePlans.codes.deleteTitle')).toBeTruthy()
|
||||
fireEvent.click(within(deleteDialog).getByRole('button', { name: 'admin.storagePlans.codes.delete' }))
|
||||
|
||||
await waitFor(() => expect(deleteStorageRedemptionCode).toHaveBeenCalledWith('ZS-CODE-1', expect.anything()))
|
||||
})
|
||||
|
||||
it('shows redemption codes in a table and opens generation fields in a dialog', async () => {
|
||||
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
|
||||
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })
|
||||
|
||||
Reference in New Issue
Block a user