mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: manage Cloud storage packages
Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user