feat: manage Cloud storage packages

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
This commit is contained in:
Jasper Van
2026-05-06 16:46:10 -04:00
committed by GitHub
parent a4f9e3ff6a
commit df2e2a8cd3
12 changed files with 314 additions and 59 deletions
@@ -134,6 +134,11 @@ beforeEach(() => {
status: 200,
json: async () => ({
id: String(url).split('/').at(-1)?.startsWith('pkg-') ? String(url).split('/').at(-1) : 'cloud-pkg-1',
name: 'Small',
description: 'starter',
resourceType: 'storage',
resourceBytes: 4096,
prices: [{ currency: 'usd', amount: 500 }],
active: true,
sortOrder: 1,
createdAt: '2026-05-06T00:00:00.000Z',
@@ -913,6 +918,25 @@ describe('Quota Store API', () => {
expect(init.method).toBe('PATCH')
})
it('publishes and unpublishes packages through partial Cloud patches', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
await seedSettings(app, headers)
const res = await app.request('/api/admin/quota-store/packages/cloud-pkg-1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ active: false }),
})
expect(res.status).toBe(200)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit]
expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}/api/store/packages/cloud-pkg-1`)
expect(init.method).toBe('PATCH')
expect(JSON.parse(String(init.body))).toEqual({ active: false })
})
it('surfaces package update Cloud failures without local writes', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
+2 -1
View File
@@ -4,6 +4,7 @@ import {
cloudDeliveryEventSchema,
generateStorageCodesInputSchema,
quotaStorePackageInputSchema,
quotaStorePackagePatchSchema,
quotaStoreSettingsSchema,
redemptionInputSchema,
} from '@shared/schemas'
@@ -73,7 +74,7 @@ const adminQuotaStore = new Hono<Env>()
if ('error' in result) return c.json(result, 502)
return c.json(result)
})
.patch('/packages/:id', zValidator('json', quotaStorePackageInputSchema), async (c) => {
.patch('/packages/:id', zValidator('json', quotaStorePackagePatchSchema), async (c) => {
const result = await patchCloudWithBinding(
c,
packagesPath(c.req.param('id')),
+2
View File
@@ -21,6 +21,7 @@ export type {
CloudDeliveryEvent,
GenerateStorageCodesInput,
QuotaStorePackageInput,
QuotaStorePackagePatchInput,
QuotaStoreSettingsInput,
RedemptionInput,
StorageCodeStatus,
@@ -30,6 +31,7 @@ export {
cloudDeliveryEventSchema,
generateStorageCodesInputSchema,
quotaStorePackageInputSchema,
quotaStorePackagePatchSchema,
quotaStoreSettingsSchema,
redemptionInputSchema,
storageCodeStatusSchema,
+4 -1
View File
@@ -21,6 +21,8 @@ export const quotaStorePackageInputSchema = z.object({
sortOrder: z.number().int().default(0),
})
export const quotaStorePackagePatchSchema = quotaStorePackageInputSchema.partial()
export const checkoutInputSchema = z.object({
packageId: z.string().min(1),
targetOrgId: z.string().min(1),
@@ -79,7 +81,8 @@ export type QuotaStoreSettingsInput = z.infer<typeof quotaStoreSettingsSchema>
export type QuotaStoreResourceType = z.infer<typeof quotaStoreResourceTypeSchema>
export type QuotaStoreCurrency = z.infer<typeof quotaStoreCurrencySchema>
export type QuotaStorePackagePrice = z.infer<typeof quotaStorePackagePriceSchema>
export type QuotaStorePackageInput = z.infer<typeof quotaStorePackageInputSchema>
export type QuotaStorePackageInput = z.input<typeof quotaStorePackageInputSchema>
export type QuotaStorePackagePatchInput = z.input<typeof quotaStorePackagePatchSchema>
export type CheckoutInput = z.infer<typeof checkoutInputSchema>
export type RedemptionInput = z.infer<typeof redemptionInputSchema>
export type StorageCodeStatus = z.infer<typeof storageCodeStatusSchema>
+2 -17
View File
@@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
const units = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const
@@ -21,7 +20,6 @@ export const emptyPackageForm = {
unit: 'GB' as Unit,
usdAmount: '999',
cnyAmount: '',
active: true,
sortOrder: '0',
}
@@ -34,7 +32,6 @@ export function packageInputFromForm(form: PackageFormState): QuotaStorePackageI
resourceType: form.resourceType,
resourceBytes: Math.round(Number(form.size) * units[form.unit]),
prices: packagePricesFromForm(form),
active: form.active,
sortOrder: Math.round(Number(form.sortOrder)),
}
}
@@ -49,7 +46,6 @@ export function packageFormFromPackage(pkg: QuotaStorePackage): PackageFormState
unit: display.unit,
usdAmount: String(pkg.prices.find((price) => price.currency === 'usd')?.amount ?? ''),
cnyAmount: String(pkg.prices.find((price) => price.currency === 'cny')?.amount ?? ''),
active: pkg.active,
sortOrder: String(pkg.sortOrder),
}
}
@@ -85,7 +81,6 @@ export function StoragePlanForm({
value={form.sortOrder}
onChange={(sortOrder) => onFormChange({ ...form, sortOrder })}
/>
<PackageActiveSwitch active={form.active} onChange={(active) => onFormChange({ ...form, active })} />
<PackageFormActions
editing={editing}
available={available}
@@ -176,7 +171,7 @@ function PackageResourceTypeField({
value={form.resourceType}
onValueChange={(resourceType: 'storage' | 'traffic') => onFormChange({ ...form, resourceType })}
>
<SelectTrigger>
<SelectTrigger aria-label={t('admin.storagePlans.resourceType')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -239,7 +234,7 @@ function NumberField({
function UnitSelect({ value, onChange }: { value: Unit; onChange: (unit: Unit) => void }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectTrigger aria-label={value}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -253,16 +248,6 @@ function UnitSelect({ value, onChange }: { value: Unit; onChange: (unit: Unit) =
)
}
function PackageActiveSwitch({ active, onChange }: { active: boolean; onChange: (active: boolean) => void }) {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor="packageActive">{t('admin.storagePlans.active')}</Label>
<Switch id="packageActive" checked={active} onCheckedChange={onChange} />
</div>
)
}
function PackageFormActions({
editing,
available,
+33 -5
View File
@@ -1,4 +1,5 @@
import type { QuotaStorePackage } from '@shared/types'
import { Pencil, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -8,9 +9,15 @@ import { formatSize } from '@/lib/format'
export function StoragePlanList({
packages,
onEdit,
onDelete,
onPublishChange,
actionPending,
}: {
packages: QuotaStorePackage[]
onEdit: (pkg: QuotaStorePackage) => void
onDelete: (pkg: QuotaStorePackage) => void
onPublishChange: (pkg: QuotaStorePackage, active: boolean) => void
actionPending?: boolean
}) {
const { t } = useTranslation()
@@ -24,7 +31,7 @@ export function StoragePlanList({
<TableHead>{t('admin.storagePlans.prices')}</TableHead>
<TableHead>{t('admin.storagePlans.active')}</TableHead>
<TableHead>{t('admin.storagePlans.sortOrder')}</TableHead>
<TableHead className="w-24 text-right">{t('common.actions')}</TableHead>
<TableHead className="w-56 text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -42,10 +49,31 @@ export function StoragePlanList({
</Badge>
</TableCell>
<TableCell className="tabular-nums">{pkg.sortOrder}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => onEdit(pkg)}>
{t('common.edit')}
</Button>
<TableCell>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={actionPending}
onClick={() => onPublishChange(pkg, !pkg.active)}
>
{pkg.active ? t('admin.storagePlans.unpublish') : t('admin.storagePlans.publish')}
</Button>
<Button variant="outline" size="icon-sm" onClick={() => onEdit(pkg)} title={t('common.edit')}>
<Pencil />
<span className="sr-only">{t('common.edit')}</span>
</Button>
<Button
variant="outline"
size="icon-sm"
disabled={actionPending}
onClick={() => onDelete(pkg)}
title={t('common.delete')}
>
<Trash2 className="text-destructive" />
<span className="sr-only">{t('common.delete')}</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -19,12 +19,13 @@ import {
} from '@/components/admin/storage-redemption-code-panel'
import {
createQuotaStorePackage,
deleteQuotaStorePackage,
generateStorageRedemptionCodes,
revokeStorageRedemptionCode,
updateQuotaStorePackage,
} from '@/lib/api'
import { Button } from '../ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
type PackageFilter = 'all' | 'active' | 'disabled'
@@ -32,15 +33,19 @@ type PackageFilter = 'all' | 'active' | 'disabled'
export function usePackageEditor() {
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<QuotaStorePackage | null>(null)
const [deleting, setDeleting] = useState<QuotaStorePackage | null>(null)
const [form, setForm] = useState(emptyPackageForm)
const mutation = usePackageMutation(editing, form, () => {
setOpen(false)
setEditing(null)
setForm(emptyPackageForm)
})
const publishMutation = usePackagePublishMutation()
const deleteMutation = usePackageDeleteMutation(() => setDeleting(null))
return {
open,
editing,
deleting,
form,
setForm,
newPackage: () => {
@@ -49,7 +54,15 @@ export function usePackageEditor() {
setOpen(true)
},
mutation,
publishMutation,
deleteMutation,
edit: (pkg: QuotaStorePackage) => editPackage(pkg, setEditing, setForm, setOpen),
publish: (pkg: QuotaStorePackage, active: boolean) => publishMutation.mutate({ id: pkg.id, active }),
delete: (pkg: QuotaStorePackage) => setDeleting(pkg),
cancelDelete: () => setDeleting(null),
confirmDelete: () => {
if (deleting) deleteMutation.mutate(deleting.id)
},
cancel: () => {
setOpen(false)
setEditing(null)
@@ -97,8 +110,15 @@ export function PackagesTab({
{t('admin.storagePlans.newPackage')}
</Button>
</div>
<StoragePlanList packages={visiblePackages} onEdit={editor.edit} />
<StoragePlanList
packages={visiblePackages}
actionPending={editor.publishMutation.isPending || editor.deleteMutation.isPending}
onEdit={editor.edit}
onDelete={editor.delete}
onPublishChange={editor.publish}
/>
<PackageDialog available={available} editor={editor} />
<DeletePackageDialog editor={editor} />
</div>
)
}
@@ -127,6 +147,36 @@ function PackageDialog({ available, editor }: { available: boolean; editor: Retu
)
}
function DeletePackageDialog({ editor }: { editor: ReturnType<typeof usePackageEditor> }) {
const { t } = useTranslation()
const pkg = editor.deleting
if (!pkg) return null
return (
<Dialog
open={Boolean(pkg)}
onOpenChange={(open) => {
if (!open && !editor.deleteMutation.isPending) editor.cancelDelete()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.storagePlans.deleteTitle')}</DialogTitle>
<DialogDescription>{t('admin.storagePlans.deleteConfirm', { name: pkg.name })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={editor.cancelDelete} disabled={editor.deleteMutation.isPending}>
{t('common.cancel')}
</Button>
<Button variant="destructive" disabled={editor.deleteMutation.isPending} onClick={editor.confirmDelete}>
{editor.deleteMutation.isPending ? t('common.loading') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function CodesTab({
actions,
available,
@@ -170,6 +220,33 @@ function usePackageMutation(editing: QuotaStorePackage | null, form: typeof empt
})
}
function usePackagePublishMutation() {
const { t } = useTranslation()
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) => updateQuotaStorePackage(id, { active }),
onSuccess: (_pkg, input) => {
queryClient.invalidateQueries({ queryKey: ['admin', 'storage-plans'] })
toast.success(t(input.active ? 'admin.storagePlans.packagePublished' : 'admin.storagePlans.packageUnpublished'))
},
onError: (err) => toast.error(err.message),
})
}
function usePackageDeleteMutation(onDeleted: () => void) {
const { t } = useTranslation()
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteQuotaStorePackage,
onSuccess: () => {
onDeleted()
queryClient.invalidateQueries({ queryKey: ['admin', 'storage-plans'] })
toast.success(t('admin.storagePlans.packageDeleted'))
},
onError: (err) => toast.error(err.message),
})
}
function useGenerateCodesMutation(form: typeof emptyCodeForm, onGenerated: () => void) {
const { t } = useTranslation()
const queryClient = useQueryClient()
+7 -4
View File
@@ -1037,10 +1037,9 @@
"admin.storagePlans.enabled": "Enabled",
"admin.storagePlans.saved": "Storage plans updated",
"admin.storagePlans.packageSaved": "Package saved",
"admin.storagePlans.synced": "Package catalog synced",
"admin.storagePlans.sync": "Sync",
"admin.storagePlans.lastSync": "Last sync",
"admin.storagePlans.lastDelivery": "Last delivery",
"admin.storagePlans.packagePublished": "Package published",
"admin.storagePlans.packageUnpublished": "Package unpublished",
"admin.storagePlans.packageDeleted": "Package deleted",
"admin.storagePlans.tabError": "Unable to load this area.",
"admin.storagePlans.tabs.packages": "Packages",
"admin.storagePlans.tabs.codes": "Redemption Codes",
@@ -1069,6 +1068,10 @@
"admin.storagePlans.cnyAmount": "CNY amount (minor units)",
"admin.storagePlans.sortOrder": "Sort order",
"admin.storagePlans.active": "Active",
"admin.storagePlans.publish": "Publish",
"admin.storagePlans.unpublish": "Unpublish",
"admin.storagePlans.deleteTitle": "Delete package",
"admin.storagePlans.deleteConfirm": "Delete {{name}}? This removes the product from ZPan Cloud so users cannot buy it anymore. Existing purchases and delivered quota are not changed.",
"admin.storagePlans.noPackages": "No storage plans configured.",
"admin.storagePlans.packages.filterAll": "All packages",
"admin.storagePlans.packages.filterActive": "Active only",
+7 -4
View File
@@ -1037,10 +1037,9 @@
"admin.storagePlans.enabled": "启用",
"admin.storagePlans.saved": "存储套餐已更新",
"admin.storagePlans.packageSaved": "套餐已保存",
"admin.storagePlans.synced": "套餐目录已同步",
"admin.storagePlans.sync": "同步",
"admin.storagePlans.lastSync": "最近同步",
"admin.storagePlans.lastDelivery": "最近交付",
"admin.storagePlans.packagePublished": "套餐已发布",
"admin.storagePlans.packageUnpublished": "套餐已下架",
"admin.storagePlans.packageDeleted": "套餐已删除",
"admin.storagePlans.tabError": "无法加载此区域。",
"admin.storagePlans.tabs.packages": "套餐",
"admin.storagePlans.tabs.codes": "兑换码",
@@ -1069,6 +1068,10 @@
"admin.storagePlans.cnyAmount": "人民币金额(最小货币单位)",
"admin.storagePlans.sortOrder": "排序",
"admin.storagePlans.active": "启用",
"admin.storagePlans.publish": "发布",
"admin.storagePlans.unpublish": "下架",
"admin.storagePlans.deleteTitle": "删除套餐",
"admin.storagePlans.deleteConfirm": "确定删除 {{name}} 吗?这会从 ZPan Cloud 移除此商品,用户将无法再购买。已有购买和已交付的配额不会改变。",
"admin.storagePlans.noPackages": "暂无存储套餐。",
"admin.storagePlans.packages.filterAll": "全部套餐",
"admin.storagePlans.packages.filterActive": "仅启用",
+35 -14
View File
@@ -297,38 +297,59 @@ describe('api', () => {
expect(JSON.parse(init.body as string)).toEqual({ enabled: true })
})
it('creates and updates packages with typed RPC paths', async () => {
it('creates packages with typed RPC paths', async () => {
const payload: Parameters<typeof createQuotaStorePackage>[0] = {
name: 'Small',
resourceType: 'storage',
resourceBytes: 1024,
prices: [{ currency: 'usd' as const, amount: 500 }],
prices: [
{ currency: 'usd' as const, amount: 500 },
{ currency: 'cny' as const, amount: 3600 },
],
}
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse({ id: 'pkg-1' }))
.mockResolvedValueOnce(makeResponse({ id: 'pkg-1' }))
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'pkg-1' }))
await createQuotaStorePackage(payload)
await updateQuotaStorePackage('pkg-1', payload)
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[1] as [string, RequestInit]
expect(createUrl).toBe('/api/admin/quota-store/packages')
expect(createInit.method).toBe('POST')
expect(JSON.parse(createInit.body as string)).toEqual({
name: 'Small',
resourceType: 'storage',
resourceBytes: 1024,
prices: [{ currency: 'usd', amount: 500 }],
prices: [
{ currency: 'usd', amount: 500 },
{ currency: 'cny', amount: 3600 },
],
})
})
it('updates packages with partial typed RPC payloads', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'pkg-1', active: false }))
await updateQuotaStorePackage('pkg-1', { active: false })
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(updateUrl).toBe('/api/admin/quota-store/packages/pkg-1')
expect(updateInit.method).toBe('PATCH')
expect(JSON.parse(updateInit.body as string)).toEqual({
name: 'Small',
resourceType: 'storage',
resourceBytes: 1024,
prices: [{ currency: 'usd', amount: 500 }],
})
expect(JSON.parse(updateInit.body as string)).toEqual({ active: false })
})
it('throws ApiError for failed package writes', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse({ error: 'package create failed' }, false, 502))
.mockResolvedValueOnce(makeResponse({ error: 'package update failed' }, false, 502))
await expect(
createQuotaStorePackage({
name: 'Small',
resourceType: 'storage',
resourceBytes: 1024,
prices: [{ currency: 'usd', amount: 500 }],
}),
).rejects.toThrow('package create failed')
await expect(updateQuotaStorePackage('pkg-1', { active: false })).rejects.toThrow('package update failed')
})
it('lists and deletes admin packages', async () => {
+4 -10
View File
@@ -6,6 +6,8 @@ import type {
CreateShareRequest,
CreateStorageInput,
GenerateStorageCodesInput,
QuotaStorePackageInput,
QuotaStorePackagePatchInput,
StorageCodeStatus,
UpdateStorageInput,
} from '@shared/schemas'
@@ -317,19 +319,11 @@ export function listQuotaStorePackages() {
return unwrap<{ items: QuotaStorePackage[]; total: number }>(adminQuotaStoreApi.packages.$get())
}
export function createQuotaStorePackage(data: {
name: string
description?: string
resourceType: 'storage' | 'traffic'
resourceBytes: number
prices: Array<{ currency: 'usd' | 'cny'; amount: number }>
active?: boolean
sortOrder?: number
}) {
export function createQuotaStorePackage(data: QuotaStorePackageInput) {
return unwrap<QuotaStorePackage>(adminQuotaStoreApi.packages.$post({ json: data }))
}
export function updateQuotaStorePackage(id: string, data: Parameters<typeof createQuotaStorePackage>[0]) {
export function updateQuotaStorePackage(id: string, data: QuotaStorePackagePatchInput) {
return unwrap<QuotaStorePackage>(adminQuotaStoreApi.packages[':id'].$patch({ param: { id }, json: data }))
}
@@ -7,12 +7,14 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import {
ApiError,
createQuotaStorePackage,
deleteQuotaStorePackage,
generateStorageRedemptionCodes,
getQuotaStoreSettings,
listAdminQuotaDeliveryRecords,
listQuotaStorePackages,
listStorageRedemptionCodes,
revokeStorageRedemptionCode,
updateQuotaStorePackage,
updateQuotaStoreSettings,
} from '@/lib/api'
import { AdminStoragePlansPage } from './storage-plans'
@@ -54,6 +56,7 @@ vi.mock('@/lib/api', () => {
return {
ApiError: MockApiError,
createQuotaStorePackage: vi.fn(),
deleteQuotaStorePackage: vi.fn(),
generateStorageRedemptionCodes: vi.fn(),
getQuotaStoreSettings: vi.fn(),
listAdminQuotaDeliveryRecords: vi.fn(),
@@ -157,13 +160,52 @@ describe('AdminStoragePlansPage', () => {
{ currency: 'usd', amount: 1999 },
{ currency: 'cny', amount: 12900 },
],
active: true,
sortOrder: 2,
}),
)
expect(toast.success).toHaveBeenCalledWith('admin.storagePlans.packageSaved')
})
it('creates a traffic package with USD and CNY prices', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })
vi.mocked(createQuotaStorePackage).mockResolvedValue(quotaPackage({ id: 'pkg-traffic', resourceType: 'traffic' }))
const view = renderAdminPage()
await waitFor(() => expect(view.getByRole('button', { name: 'admin.storagePlans.newPackage' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'admin.storagePlans.newPackage' }))
const dialog = await view.findByRole('dialog')
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.packageName'), {
target: { value: '1 TB traffic' },
})
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.description'), {
target: { value: 'Download traffic' },
})
fireEvent.click(within(dialog).getByLabelText('admin.storagePlans.resourceType'))
fireEvent.click(await view.findByRole('option', { name: 'admin.storagePlans.resourceTraffic' }))
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.size'), { target: { value: '1' } })
fireEvent.click(within(dialog).getByLabelText('GB'))
fireEvent.click(await view.findByRole('option', { name: 'TB' }))
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.usdAmount'), { target: { value: '4999' } })
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.cnyAmount'), { target: { value: '32900' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
await waitFor(() =>
expect(createQuotaStorePackage).toHaveBeenCalledWith({
name: '1 TB traffic',
description: 'Download traffic',
resourceType: 'traffic',
resourceBytes: 1099511627776,
prices: [
{ currency: 'usd', amount: 4999 },
{ currency: 'cny', amount: 32900 },
],
sortOrder: 0,
}),
)
})
it('shows packages in a table and opens the package form in a dialog', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [quotaPackage()], total: 1 })
@@ -173,6 +215,9 @@ describe('AdminStoragePlansPage', () => {
await waitFor(() => expect(view.getByRole('table')).toBeTruthy())
expect(view.getByRole('columnheader', { name: 'admin.storagePlans.packageName' })).toBeTruthy()
expect(view.getByRole('columnheader', { name: 'admin.storagePlans.prices' })).toBeTruthy()
expect(view.queryByRole('button', { name: 'admin.storagePlans.sync' })).toBeNull()
expect(view.queryByText('admin.storagePlans.lastSync')).toBeNull()
expect(view.queryByText('admin.storagePlans.lastDelivery')).toBeNull()
expect(view.queryByLabelText('admin.storagePlans.packageName')).toBeNull()
fireEvent.click(view.getByRole('button', { name: 'admin.storagePlans.newPackage' }))
@@ -180,6 +225,7 @@ describe('AdminStoragePlansPage', () => {
const dialog = await view.findByRole('dialog')
expect(within(dialog).getByText('admin.storagePlans.newPackage')).toBeTruthy()
expect(within(dialog).getByLabelText('admin.storagePlans.packageName')).toBeTruthy()
expect(within(dialog).queryByLabelText('admin.storagePlans.active')).toBeNull()
})
it('opens existing packages for editing in the package dialog', async () => {
@@ -194,6 +240,74 @@ describe('AdminStoragePlansPage', () => {
const dialog = await view.findByRole('dialog')
expect(within(dialog).getByText('admin.storagePlans.editPackage')).toBeTruthy()
expect(within(dialog).getByLabelText('admin.storagePlans.packageName')).toHaveProperty('value', '500 GB')
expect(within(dialog).queryByLabelText('admin.storagePlans.active')).toBeNull()
})
it('edits package content without sending active from the form', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [quotaPackage({ active: false })], total: 1 })
vi.mocked(updateQuotaStorePackage).mockResolvedValue(quotaPackage({ active: false, name: '200 GB' }))
const view = renderAdminPage()
await waitFor(() => expect(view.getByRole('button', { name: 'common.edit' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'common.edit' }))
const dialog = await view.findByRole('dialog')
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.packageName'), { target: { value: '200 GB' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
await waitFor(() =>
expect(updateQuotaStorePackage).toHaveBeenCalledWith('pkg-1', {
name: '200 GB',
description: 'Extra storage',
resourceType: 'storage',
resourceBytes: 107374182400,
prices: [{ currency: 'usd', amount: 999 }],
sortOrder: 1,
}),
)
})
it('publishes and unpublishes packages from table actions', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({
items: [
quotaPackage({ id: 'pkg-active', name: 'Active plan', active: true }),
quotaPackage({ id: 'pkg-disabled', name: 'Disabled plan', active: false }),
],
total: 2,
})
vi.mocked(updateQuotaStorePackage)
.mockResolvedValueOnce(quotaPackage({ id: 'pkg-active', active: false }))
.mockResolvedValueOnce(quotaPackage({ id: 'pkg-disabled', active: true }))
const view = renderAdminPage()
await waitFor(() => expect(view.getByRole('button', { name: 'admin.storagePlans.unpublish' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'admin.storagePlans.unpublish' }))
await waitFor(() => expect(updateQuotaStorePackage).toHaveBeenCalledWith('pkg-active', { active: false }))
fireEvent.click(view.getByRole('button', { name: 'admin.storagePlans.publish' }))
await waitFor(() => expect(updateQuotaStorePackage).toHaveBeenCalledWith('pkg-disabled', { active: true }))
})
it('deletes packages only after confirmation', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [quotaPackage({ name: 'Delete me' })], total: 1 })
vi.mocked(deleteQuotaStorePackage).mockResolvedValue({ id: 'pkg-1', deleted: true })
const view = renderAdminPage()
await waitFor(() => expect(view.getByRole('button', { name: 'common.delete' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'common.delete' }))
expect(deleteQuotaStorePackage).not.toHaveBeenCalled()
const dialog = await view.findByRole('dialog')
expect(within(dialog).getByText('admin.storagePlans.deleteTitle')).toBeTruthy()
expect(within(dialog).getByText('admin.storagePlans.deleteConfirm')).toBeTruthy()
fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(deleteQuotaStorePackage).toHaveBeenCalledWith('pkg-1', expect.anything()))
})
it('filters packages from the packages toolbar', async () => {