mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-31 02:41:33 +08:00
3411 lines
122 KiB
TypeScript
3411 lines
122 KiB
TypeScript
// Tests for src/lib/api.ts — covers all public API helper functions
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
batchDeleteObjects,
|
|
batchDeleteUsers,
|
|
batchMoveObjects,
|
|
batchTrashObjects,
|
|
batchUpdateUserStatus,
|
|
buildShareObjectUrl,
|
|
cancelBackgroundJob,
|
|
cancelCloudOrder,
|
|
cancelUpload,
|
|
confirmIhostImage,
|
|
confirmUpload,
|
|
connectCloud,
|
|
continueCloudOrderPayment,
|
|
copyObject,
|
|
createAnnouncement,
|
|
createBackgroundJob,
|
|
createCloudBillingPortalSession,
|
|
createCloudCheckout,
|
|
createCloudGiftCards,
|
|
createCloudProduct,
|
|
createDownloadTask,
|
|
createIhostApiKey,
|
|
createIhostImagePresign,
|
|
createObject,
|
|
createObjectUploadSession,
|
|
createShare,
|
|
createSiteInvitation,
|
|
createStorage,
|
|
createWebDavAppPassword,
|
|
deleteAnnouncement,
|
|
deleteAvatar,
|
|
deleteCloudGiftCard,
|
|
deleteCloudProduct,
|
|
deleteDownloader,
|
|
deleteIhostConfig,
|
|
deleteIhostImage,
|
|
deleteObject,
|
|
deleteShare,
|
|
deleteStorage,
|
|
deleteTeamLogo,
|
|
deleteUser,
|
|
disableCloudGiftCard,
|
|
disconnectCloud,
|
|
emptyTrash,
|
|
enableIhostFeature,
|
|
getAnnouncement,
|
|
getBackgroundJob,
|
|
getBranding,
|
|
getCloudCredits,
|
|
getCloudStoreSettings,
|
|
getEmailConfig,
|
|
getIhostConfig,
|
|
getLicensingStatus,
|
|
getObject,
|
|
getProfile,
|
|
getSession,
|
|
getShare,
|
|
getSiteInvitation,
|
|
getStorage,
|
|
getSystemOption,
|
|
getUnreadCount,
|
|
getUserQuota,
|
|
grantUserEntitlement,
|
|
listActiveAnnouncements,
|
|
listAdminAnnouncements,
|
|
listAdminAuditLogs,
|
|
listAdminCloudCreditProducts,
|
|
listAdminCloudOrders,
|
|
listAdminCloudProducts,
|
|
listAnnouncements,
|
|
listAuthProviders,
|
|
listBackgroundJobs,
|
|
listCloudCreditLedgerEntries,
|
|
listCloudCreditProducts,
|
|
listCloudGiftCards,
|
|
listCloudOrders,
|
|
listCloudProducts,
|
|
listCloudStoreTargets,
|
|
listDownloaders,
|
|
listDownloadTasks,
|
|
listIhostApiKeys,
|
|
listIhostImages,
|
|
listNotifications,
|
|
listObjects,
|
|
listQuotas,
|
|
listShareObjects,
|
|
listShares,
|
|
listSiteInvitations,
|
|
listStorages,
|
|
listSystemOptions,
|
|
listUserEntitlements,
|
|
listUsers,
|
|
listWebDavAppPasswords,
|
|
markAllNotificationsRead,
|
|
markNotificationRead,
|
|
patchObjectUploadSession,
|
|
pollPairing,
|
|
presignObjectUploadParts,
|
|
redeemCloudGiftCard,
|
|
refreshLicense,
|
|
resendSiteInvitation,
|
|
resetBrandingField,
|
|
restoreObject,
|
|
retryBackgroundJob,
|
|
revokeIhostApiKey,
|
|
revokeSiteInvitation,
|
|
revokeWebDavAppPassword,
|
|
saveBranding,
|
|
saveEmailConfig,
|
|
saveShareToDrive,
|
|
sendDownloaderHeartbeat,
|
|
setSystemOption,
|
|
testEmail,
|
|
trashObject,
|
|
updateAnnouncement,
|
|
updateCloudProduct,
|
|
updateCloudStoreSettings,
|
|
updateDownloader,
|
|
updateDownloadTask,
|
|
updateIhostConfig,
|
|
updateObject,
|
|
updateStorage,
|
|
updateUserStatus,
|
|
uploadAvatar,
|
|
uploadTeamLogo,
|
|
uploadToS3,
|
|
verifySharePassword,
|
|
} from './api'
|
|
|
|
function makeResponse(body: unknown, ok = true, status = 200): Response {
|
|
return {
|
|
ok,
|
|
status,
|
|
statusText: ok ? 'OK' : 'Bad Request',
|
|
json: async () => body,
|
|
} as unknown as Response
|
|
}
|
|
|
|
describe('api', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('listObjects', () => {
|
|
it('calls correct URL with defaults', async () => {
|
|
const fetchMock = vi.mocked(fetch)
|
|
fetchMock.mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
|
|
|
|
await listObjects('root')
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects?')
|
|
expect(url).toContain('parent=root')
|
|
expect(url).toContain('status=active')
|
|
expect(url).toContain('page=1')
|
|
expect(url).toContain('pageSize=500')
|
|
})
|
|
|
|
it('uses provided status, page, and pageSize', async () => {
|
|
const fetchMock = vi.mocked(fetch)
|
|
fetchMock.mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 2, pageSize: 20 }))
|
|
|
|
await listObjects('folder1', 'trashed', 2, 20)
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('status=trashed')
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=20')
|
|
})
|
|
|
|
it('returns parsed paginated response', async () => {
|
|
const payload = { items: [{ id: 'abc' }], total: 1, page: 1, pageSize: 500 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listObjects('root')
|
|
|
|
expect(result).toEqual(payload)
|
|
})
|
|
|
|
it('throws when response is not ok', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(listObjects('root')).rejects.toThrow('forbidden')
|
|
})
|
|
|
|
it('falls back to statusText when error body has no error field', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({}, false, 500))
|
|
|
|
await expect(listObjects('root')).rejects.toThrow('Bad Request')
|
|
})
|
|
|
|
it('falls back to statusText when json parse fails', async () => {
|
|
const res = {
|
|
ok: false,
|
|
statusText: 'Service Unavailable',
|
|
json: async () => {
|
|
throw new Error('parse error')
|
|
},
|
|
} as unknown as Response
|
|
vi.mocked(fetch).mockResolvedValueOnce(res)
|
|
|
|
await expect(listObjects('root')).rejects.toThrow('Service Unavailable')
|
|
})
|
|
|
|
it('passes credentials: include', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
|
|
|
|
await listObjects('root')
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(init.credentials).toBe('include')
|
|
})
|
|
})
|
|
|
|
describe('getObject', () => {
|
|
it('fetches object by id', async () => {
|
|
const obj = { id: 'id1', name: 'file.txt', downloadUrl: 'https://s3/file.txt' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
|
|
|
|
const result = await getObject('id1')
|
|
|
|
expect(result).toEqual(obj)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toBe('/api/objects/id1')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(getObject('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('site invitations api', () => {
|
|
it('lists site invitations with pagination', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
|
|
await listSiteInvitations(2, 10)
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/site-invitations?')
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=10')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('creates a site invitation', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'invite-1', email: 'new@example.com' }))
|
|
|
|
await createSiteInvitation('new@example.com')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/site-invitations')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(JSON.stringify({ email: 'new@example.com' }))
|
|
})
|
|
|
|
it('resends a site invitation', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'invite-1', email: 'new@example.com' }))
|
|
|
|
await resendSiteInvitation('invite-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/site-invitations/invite-1/resend')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('revokes a site invitation', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'invite-1', revoked: true }))
|
|
|
|
await revokeSiteInvitation('invite-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/site-invitations/invite-1')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('gets a public site invitation by token', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'invite-1', token: 'token-1' }))
|
|
|
|
await getSiteInvitation('token-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/site-invitations/token-1')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('throws ApiError for failed site invitation create', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'duplicate invitation' }, false, 409))
|
|
|
|
await expect(createSiteInvitation('new@example.com')).rejects.toThrow('duplicate invitation')
|
|
})
|
|
})
|
|
|
|
describe('quota store api', () => {
|
|
it('gets admin settings', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null))
|
|
|
|
await getCloudStoreSettings()
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/store/settings')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('updates admin settings payload', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ enabled: true }))
|
|
|
|
await updateCloudStoreSettings({
|
|
enabled: true,
|
|
})
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/store/settings')
|
|
expect(init.method).toBe('PUT')
|
|
expect(JSON.parse(init.body as string)).toEqual({ enabled: true })
|
|
})
|
|
|
|
it('creates packages with typed RPC paths', async () => {
|
|
const payload: Parameters<typeof createCloudProduct>[0] = {
|
|
type: 'store_item',
|
|
name: 'Small',
|
|
description: '',
|
|
metadata: {
|
|
deliverable: { type: 'zpan.plan', storageBytes: 1024, includedCredits: 100 },
|
|
},
|
|
prices: [
|
|
{
|
|
currency: 'usd',
|
|
amount: 500,
|
|
recurring: { interval: 'month', intervalCount: 1 },
|
|
metadata: { creditGrantType: 'subscription_grant', creditAmount: '100' },
|
|
},
|
|
],
|
|
active: true,
|
|
sortOrder: 0,
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'pkg-1' }))
|
|
|
|
await createCloudProduct(payload)
|
|
|
|
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(createUrl).toBe('/api/admin/store/packages')
|
|
expect(createInit.method).toBe('POST')
|
|
expect(JSON.parse(createInit.body as string)).toEqual({
|
|
type: 'store_item',
|
|
name: 'Small',
|
|
description: '',
|
|
metadata: {
|
|
deliverable: { type: 'zpan.plan', storageBytes: 1024, includedCredits: 100 },
|
|
},
|
|
prices: [
|
|
{
|
|
currency: 'usd',
|
|
amount: 500,
|
|
recurring: { interval: 'month', intervalCount: 1 },
|
|
metadata: { creditGrantType: 'subscription_grant', creditAmount: '100' },
|
|
},
|
|
],
|
|
active: true,
|
|
sortOrder: 0,
|
|
})
|
|
})
|
|
|
|
it('updates packages with partial typed RPC payloads', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'pkg-1', active: false }))
|
|
|
|
await updateCloudProduct('pkg-1', { active: false })
|
|
|
|
const [updateUrl, updateInit] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(updateUrl).toBe('/api/admin/store/packages/pkg-1')
|
|
expect(updateInit.method).toBe('PATCH')
|
|
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(
|
|
createCloudProduct({
|
|
type: 'store_item',
|
|
name: 'Small',
|
|
description: '',
|
|
metadata: { deliverable: { type: 'zpan.credits', includedCredits: 500 } },
|
|
prices: [{ currency: 'usd', amount: 500, metadata: { creditGrantType: 'top_up', creditAmount: '500' } }],
|
|
active: true,
|
|
sortOrder: 0,
|
|
}),
|
|
).rejects.toThrow('package create failed')
|
|
await expect(updateCloudProduct('pkg-1', { active: false })).rejects.toThrow('package update failed')
|
|
})
|
|
|
|
it('lists and deletes admin packages', async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
.mockResolvedValueOnce(makeResponse({ id: 'pkg-1', deleted: true }))
|
|
|
|
await listAdminCloudProducts()
|
|
await deleteCloudProduct('pkg-1')
|
|
|
|
expect((vi.mocked(fetch).mock.calls[0] as [string])[0]).toBe('/api/admin/store/packages')
|
|
const [deleteUrl, deleteInit] = vi.mocked(fetch).mock.calls[1] as [string, RequestInit]
|
|
expect(deleteUrl).toBe('/api/admin/store/packages/pkg-1')
|
|
expect(deleteInit.method).toBe('DELETE')
|
|
})
|
|
|
|
it('calls admin gift card and order endpoints', async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
.mockResolvedValueOnce(makeResponse([{ code: 'ZS123' }]))
|
|
.mockResolvedValueOnce(makeResponse({ code: 'ZS123', disabled: true }))
|
|
.mockResolvedValueOnce(makeResponse({ code: 'ZS123', deleted: true }))
|
|
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
|
|
await listCloudGiftCards('active')
|
|
const createdGiftCards = await createCloudGiftCards({ credits: 1024, campaignId: 'campaign-1', count: 3 })
|
|
await disableCloudGiftCard('ZS123')
|
|
await deleteCloudGiftCard('ZS123')
|
|
await listAdminCloudOrders({ limit: 100, offset: 100 })
|
|
|
|
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
|
|
expect(calls[0][0]).toBe('/api/admin/store/gift-cards?status=active')
|
|
expect(calls[0][1].method).toBe('GET')
|
|
expect(calls[1][0]).toBe('/api/admin/store/gift-cards')
|
|
expect(calls[1][1].method).toBe('POST')
|
|
expect(JSON.parse(calls[1][1].body as string)).toEqual({
|
|
credits: 1024,
|
|
campaignId: 'campaign-1',
|
|
count: 3,
|
|
})
|
|
expect(createdGiftCards).toEqual([{ code: 'ZS123' }])
|
|
expect(calls[2][0]).toBe('/api/admin/store/gift-cards/ZS123')
|
|
expect(calls[2][1].method).toBe('PATCH')
|
|
expect(JSON.parse(calls[2][1].body as string)).toEqual({ disabled: true })
|
|
expect(calls[3][0]).toBe('/api/admin/store/gift-cards/ZS123')
|
|
expect(calls[3][1].method).toBe('DELETE')
|
|
expect(calls[4][0]).toBe('/api/admin/store/orders?limit=100&offset=100')
|
|
})
|
|
|
|
it('calls admin credits product endpoint', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
|
|
await listAdminCloudCreditProducts()
|
|
|
|
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
|
|
expect(calls[0][0]).toBe('/api/admin/store/credits/products')
|
|
expect(calls[0][1].method).toBe('GET')
|
|
})
|
|
|
|
it('calls user store endpoints', async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
.mockResolvedValueOnce(makeResponse({ orderId: 'order-1', url: 'https://cloud.example/checkout' }))
|
|
.mockResolvedValueOnce(makeResponse({ url: 'https://billing.stripe.test/1', stripeSubscriptionId: 'sub_1' }))
|
|
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
|
|
|
await listCloudProducts()
|
|
await listCloudCreditProducts()
|
|
await listCloudStoreTargets()
|
|
await createCloudCheckout('pkg-1', 'price-usd')
|
|
await createCloudBillingPortalSession()
|
|
await listCloudOrders({ limit: 100, offset: 100 })
|
|
|
|
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
|
|
expect(calls[0][0]).toBe('/api/store/packages')
|
|
expect(calls[1][0]).toBe('/api/store/credits/products')
|
|
expect(calls[2][0]).toBe('/api/store/targets')
|
|
expect(calls[3][0]).toBe('/api/store/checkouts')
|
|
expect(JSON.parse(calls[3][1].body as string)).toEqual({
|
|
packageId: 'pkg-1',
|
|
priceId: 'price-usd',
|
|
})
|
|
expect(calls[4][0]).toBe('/api/store/billing-portal-sessions')
|
|
expect(calls[4][1].method).toBe('POST')
|
|
expect(calls[5][0]).toBe('/api/store/orders?limit=100&offset=100')
|
|
})
|
|
|
|
it('calls credit balance, credit activity, redemption, and order action endpoints', async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce(
|
|
makeResponse({
|
|
balance: 500,
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
makeResponse({
|
|
items: [
|
|
{
|
|
id: 'ledger-1',
|
|
creditAccountId: 'credit-account-1',
|
|
creditBucketId: 'credit-bucket-1',
|
|
storeId: 'store-1',
|
|
customerId: 'org-1',
|
|
amount: 500,
|
|
direction: 'credit',
|
|
status: 'posted',
|
|
sourceType: 'gift_card_redemption',
|
|
sourceId: 'gc-1',
|
|
orderId: null,
|
|
paymentId: null,
|
|
createdAt: '2026-05-08T00:00:00.000Z',
|
|
},
|
|
],
|
|
total: 1,
|
|
limit: 50,
|
|
offset: 0,
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
makeResponse({
|
|
redeemedCredits: 1000,
|
|
entries: [],
|
|
failures: [],
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(makeResponse({ orderId: 'order-1', url: 'https://cloud.example/pay' }))
|
|
.mockResolvedValueOnce(makeResponse({ id: 'order-1', status: 'canceled' }))
|
|
|
|
const credits = await getCloudCredits()
|
|
const ledger = await listCloudCreditLedgerEntries()
|
|
const redeem = await redeemCloudGiftCard('GIFT-123')
|
|
const payment = await continueCloudOrderPayment('order-1')
|
|
const canceled = await cancelCloudOrder('order-1')
|
|
|
|
expect(credits).toEqual({ balance: 500 })
|
|
expect(ledger).toEqual({
|
|
items: [
|
|
{
|
|
id: 'ledger-1',
|
|
creditAccountId: 'credit-account-1',
|
|
creditBucketId: 'credit-bucket-1',
|
|
storeId: 'store-1',
|
|
customerId: 'org-1',
|
|
amount: 500,
|
|
direction: 'credit',
|
|
status: 'posted',
|
|
sourceType: 'gift_card_redemption',
|
|
sourceId: 'gc-1',
|
|
orderId: null,
|
|
paymentId: null,
|
|
createdAt: '2026-05-08T00:00:00.000Z',
|
|
},
|
|
],
|
|
total: 1,
|
|
limit: 50,
|
|
offset: 0,
|
|
})
|
|
expect(redeem).toEqual({ redeemedCredits: 1000, entries: [], failures: [] })
|
|
expect(payment).toEqual({ orderId: 'order-1', url: 'https://cloud.example/pay' })
|
|
expect(canceled).toEqual({ id: 'order-1', status: 'canceled' })
|
|
|
|
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
|
|
expect(calls[0][0]).toBe('/api/store/credits')
|
|
expect(calls[1][0]).toBe('/api/store/credits/ledger-entries')
|
|
expect(calls[2][0]).toBe('/api/store/credits/redemptions')
|
|
expect(calls[2][1].method).toBe('POST')
|
|
expect(JSON.parse(calls[2][1].body as string)).toEqual({ code: 'GIFT-123' })
|
|
expect(calls[3][0]).toBe('/api/store/orders/order-1/payments')
|
|
expect(calls[3][1].method).toBe('POST')
|
|
expect(calls[4][0]).toBe('/api/store/orders/order-1')
|
|
expect(calls[4][1].method).toBe('PATCH')
|
|
expect(JSON.parse(calls[4][1].body as string)).toEqual({ status: 'canceled' })
|
|
})
|
|
|
|
it('throws ApiError for quota store failures', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'feature_not_available' }, false, 402))
|
|
|
|
await expect(listAdminCloudProducts()).rejects.toThrow('feature_not_available')
|
|
})
|
|
|
|
it.each([
|
|
['getCloudStoreSettings', () => getCloudStoreSettings()],
|
|
['updateCloudStoreSettings', () => updateCloudStoreSettings({ enabled: true })],
|
|
['listAdminCloudProducts', () => listAdminCloudProducts()],
|
|
[
|
|
'createCloudProduct',
|
|
() =>
|
|
createCloudProduct({
|
|
type: 'store_item',
|
|
name: 'Small',
|
|
description: '',
|
|
metadata: { deliverable: { type: 'zpan.credits', includedCredits: 500 } },
|
|
prices: [{ currency: 'usd', amount: 500, metadata: { creditGrantType: 'top_up', creditAmount: '500' } }],
|
|
active: true,
|
|
sortOrder: 0,
|
|
}),
|
|
],
|
|
[
|
|
'updateCloudProduct',
|
|
() =>
|
|
updateCloudProduct('pkg-1', {
|
|
type: 'store_item',
|
|
name: 'Small',
|
|
description: '',
|
|
metadata: { deliverable: { type: 'zpan.credits', includedCredits: 500 } },
|
|
prices: [{ currency: 'usd', amount: 500, metadata: { creditGrantType: 'top_up', creditAmount: '500' } }],
|
|
sortOrder: 0,
|
|
}),
|
|
],
|
|
['deleteCloudProduct', () => deleteCloudProduct('pkg-1')],
|
|
['listCloudGiftCards', () => listCloudGiftCards()],
|
|
['createCloudGiftCards', () => createCloudGiftCards({ credits: 1024, count: 1 })],
|
|
['disableCloudGiftCard', () => disableCloudGiftCard('ZS123')],
|
|
['deleteCloudGiftCard', () => deleteCloudGiftCard('ZS123')],
|
|
['listAdminCloudOrders', () => listAdminCloudOrders()],
|
|
['listCloudProducts', () => listCloudProducts()],
|
|
['listCloudStoreTargets', () => listCloudStoreTargets()],
|
|
['getCloudCredits', () => getCloudCredits()],
|
|
['listCloudCreditLedgerEntries', () => listCloudCreditLedgerEntries()],
|
|
['redeemCloudGiftCard', () => redeemCloudGiftCard('GIFT-123')],
|
|
['createCloudCheckout', () => createCloudCheckout('pkg-1')],
|
|
['listCloudOrders', () => listCloudOrders()],
|
|
])('throws ApiError for %s failures', async (_name, call) => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'quota store failed' }, false, 400))
|
|
|
|
await expect(call()).rejects.toThrow('quota store failed')
|
|
})
|
|
})
|
|
|
|
describe('createObject', () => {
|
|
it('posts to /api/objects with JSON body', async () => {
|
|
const created = { id: 'new1', name: 'doc.pdf', uploadUrl: 'https://s3/presigned' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(created))
|
|
|
|
const result = await createObject({
|
|
name: 'doc.pdf',
|
|
type: 'application/pdf',
|
|
size: 1024,
|
|
parent: 'root',
|
|
dirtype: 0,
|
|
})
|
|
|
|
expect(result).toEqual(created)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects')
|
|
expect(init.method).toBe('POST')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ name: 'doc.pdf', type: 'application/pdf', size: 1024, parent: 'root', dirtype: 0 })
|
|
const headers =
|
|
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
|
|
expect(headers.get('Content-Type')).toContain('application/json')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'quota exceeded' }, false, 422))
|
|
|
|
await expect(createObject({ name: 'f', type: 't', parent: 'p', dirtype: 0 })).rejects.toThrow('quota exceeded')
|
|
})
|
|
})
|
|
|
|
describe('updateObject', () => {
|
|
it('patches object by id with name', async () => {
|
|
const updated = { id: 'id1', name: 'renamed.txt' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(updated))
|
|
|
|
const result = await updateObject('id1', { name: 'renamed.txt' })
|
|
|
|
expect(result).toEqual(updated)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/objects/id1')
|
|
expect(init.method).toBe('PATCH')
|
|
expect(init.body).toBe(JSON.stringify({ action: 'update', name: 'renamed.txt' }))
|
|
})
|
|
|
|
it('patches object by id with parent', async () => {
|
|
const updated = { id: 'id1', parent: 'folder2' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(updated))
|
|
|
|
await updateObject('id1', { parent: 'folder2' })
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(init.body).toBe(JSON.stringify({ action: 'update', parent: 'folder2' }))
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(updateObject('id1', { name: 'x' })).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('confirmUpload', () => {
|
|
it('patches with action: confirm', async () => {
|
|
const obj = { id: 'id1', status: 'active' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
|
|
|
|
const result = await confirmUpload('id1')
|
|
|
|
expect(result).toEqual(obj)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/objects/id1')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'confirm' })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(confirmUpload('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('cancelUpload', () => {
|
|
it('patches with action: cancel', async () => {
|
|
const payload = { id: 'id1', cancelled: true }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await cancelUpload('id1')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/objects/id1')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'cancel' })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(cancelUpload('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('deleteObject', () => {
|
|
it('sends DELETE request and returns deleted flag', async () => {
|
|
const payload = { id: 'id1', deleted: true }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await deleteObject('id1')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/objects/id1')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(deleteObject('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('copyObject', () => {
|
|
it('posts to /copy endpoint with copyFrom and parent in body', async () => {
|
|
const copy = { id: 'copy1', name: 'file.txt' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(copy))
|
|
|
|
const result = await copyObject('id1', 'folder2')
|
|
|
|
expect(result).toEqual(copy)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/copy')
|
|
expect(init.method).toBe('POST')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ copyFrom: 'id1', parent: 'folder2' })
|
|
const headers =
|
|
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
|
|
expect(headers.get('Content-Type')).toContain('application/json')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'conflict' }, false, 409))
|
|
|
|
await expect(copyObject('id1', 'folder2')).rejects.toThrow('conflict')
|
|
})
|
|
})
|
|
|
|
describe('uploadToS3', () => {
|
|
class MockXMLHttpRequest {
|
|
static instances: MockXMLHttpRequest[] = []
|
|
upload = { onprogress: null as ((event: ProgressEvent) => void) | null }
|
|
onload: (() => void) | null = null
|
|
onerror: (() => void) | null = null
|
|
onabort: (() => void) | null = null
|
|
status = 200
|
|
method = ''
|
|
url = ''
|
|
body: unknown
|
|
headers: Record<string, string> = {}
|
|
|
|
constructor() {
|
|
MockXMLHttpRequest.instances.push(this)
|
|
}
|
|
|
|
open(method: string, url: string) {
|
|
this.method = method
|
|
this.url = url
|
|
}
|
|
|
|
setRequestHeader(key: string, value: string) {
|
|
this.headers[key] = value
|
|
}
|
|
|
|
send(body: unknown) {
|
|
this.body = body
|
|
}
|
|
|
|
abort() {
|
|
this.onabort?.()
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
MockXMLHttpRequest.instances = []
|
|
vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest)
|
|
})
|
|
|
|
it('PUTs file to presigned URL with correct content-type', async () => {
|
|
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
|
|
const promise = uploadToS3('https://s3/presigned', file)
|
|
const xhr = MockXMLHttpRequest.instances[0]
|
|
xhr.onload?.()
|
|
await promise
|
|
|
|
expect(xhr.url).toBe('https://s3/presigned')
|
|
expect(xhr.method).toBe('PUT')
|
|
expect(xhr.body).toBe(file)
|
|
expect(xhr.headers['Content-Type']).toBe('text/plain')
|
|
})
|
|
|
|
it('falls back to application/octet-stream when file type is empty', async () => {
|
|
const file = new File(['data'], 'blob') // no type
|
|
const promise = uploadToS3('https://s3/presigned', file)
|
|
const xhr = MockXMLHttpRequest.instances[0]
|
|
xhr.onload?.()
|
|
await promise
|
|
|
|
expect(xhr.headers['Content-Type']).toBe('application/octet-stream')
|
|
})
|
|
|
|
it('does not pass credentials on S3 upload', async () => {
|
|
const file = new File(['x'], 'x.bin')
|
|
const promise = uploadToS3('https://s3/presigned', file)
|
|
const xhr = MockXMLHttpRequest.instances[0]
|
|
xhr.onload?.()
|
|
await promise
|
|
|
|
expect('withCredentials' in xhr).toBe(false)
|
|
})
|
|
|
|
it('throws when S3 upload fails', async () => {
|
|
const file = new File(['x'], 'x.bin')
|
|
const promise = uploadToS3('https://s3/presigned', file)
|
|
const xhr = MockXMLHttpRequest.instances[0]
|
|
xhr.status = 403
|
|
xhr.onload?.()
|
|
await expect(promise).rejects.toThrow('Upload failed')
|
|
})
|
|
|
|
it('reports upload progress', async () => {
|
|
const onProgress = vi.fn()
|
|
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
|
|
Object.defineProperty(file, 'size', { value: 10 })
|
|
|
|
const promise = uploadToS3('https://s3/presigned', file, { onProgress })
|
|
const xhr = MockXMLHttpRequest.instances[0]
|
|
xhr.upload.onprogress?.({ loaded: 4, total: 10, lengthComputable: true } as ProgressEvent)
|
|
xhr.onload?.()
|
|
await promise
|
|
|
|
expect(onProgress).toHaveBeenCalledWith({ loaded: 4, total: 10 })
|
|
expect(onProgress).toHaveBeenLastCalledWith({ loaded: 10, total: 10 })
|
|
})
|
|
|
|
it('rejects with AbortError when aborted', async () => {
|
|
const controller = new AbortController()
|
|
const file = new File(['x'], 'x.bin')
|
|
const promise = uploadToS3('https://s3/presigned', file, { signal: controller.signal })
|
|
|
|
controller.abort()
|
|
|
|
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
|
|
})
|
|
|
|
it('sets Content-Disposition header when option is provided', async () => {
|
|
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
|
|
const promise = uploadToS3('https://s3/presigned', file, {
|
|
contentDisposition: 'attachment; filename="hello.txt"',
|
|
})
|
|
const xhr = MockXMLHttpRequest.instances[0]
|
|
xhr.onload?.()
|
|
await promise
|
|
|
|
expect(xhr.headers['Content-Disposition']).toBe('attachment; filename="hello.txt"')
|
|
})
|
|
})
|
|
|
|
describe('restoreObject', () => {
|
|
it('sends PATCH with action: restore for the given id', async () => {
|
|
const obj = { id: 'id1', status: 'active' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
|
|
|
|
const result = await restoreObject('id1')
|
|
|
|
expect(result).toEqual(obj)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/id1')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'restore' })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(restoreObject('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('emptyTrash', () => {
|
|
it('sends DELETE to trash endpoint', async () => {
|
|
const payload = { purged: 5 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await emptyTrash()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/trash')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'server error' }, false, 500))
|
|
|
|
await expect(emptyTrash()).rejects.toThrow('server error')
|
|
})
|
|
})
|
|
|
|
describe('object upload sessions api', () => {
|
|
it('creates an upload session for an object', async () => {
|
|
const payload = { id: 'upload-1', object: { id: 'obj-1' } }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await createObjectUploadSession('obj-1', {
|
|
partSize: 5 * 1024 * 1024,
|
|
})
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/obj-1/uploads')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(JSON.stringify({ partSize: 5 * 1024 * 1024 }))
|
|
})
|
|
|
|
it('presigns upload session parts', async () => {
|
|
const payload = { parts: [{ partNumber: 1, uploadUrl: 'https://s3/part-1' }] }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await presignObjectUploadParts('obj-1', 'upload-1', { partNumbers: [1] })
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/obj-1/uploads/upload-1/parts')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(JSON.stringify({ partNumbers: [1] }))
|
|
})
|
|
|
|
it('patches an upload session', async () => {
|
|
const payload = { id: 'upload-1', status: 'completed', object: { id: 'obj-1' } }
|
|
const body = { action: 'complete' as const, parts: [{ partNumber: 1, etag: 'etag-1' }] }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await patchObjectUploadSession('obj-1', 'upload-1', body)
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/obj-1/uploads/upload-1')
|
|
expect(init.method).toBe('PATCH')
|
|
expect(init.body).toBe(JSON.stringify(body))
|
|
})
|
|
|
|
it('throws ApiError on upload session failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'upload denied' }, false, 403))
|
|
|
|
await expect(createObjectUploadSession('obj-1', { partSize: 5 * 1024 * 1024 })).rejects.toThrow('upload denied')
|
|
})
|
|
})
|
|
|
|
describe('remote download api', () => {
|
|
it('lists download tasks with filters', async () => {
|
|
const payload = { items: [], total: 0, page: 2, pageSize: 10 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listDownloadTasks({ status: 'running', assignedTo: 'me', page: 2, pageSize: 10 })
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/download-tasks?')
|
|
expect(url).toContain('status=running')
|
|
expect(url).toContain('assignedTo=me')
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=10')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('creates a download task', async () => {
|
|
const payload = { id: 'task-1', status: 'queued' }
|
|
const body = { source: { type: 'http' as const, uri: 'https://example.com/file.zip' }, targetFolder: 'root' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await createDownloadTask(body)
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/download-tasks')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(JSON.stringify(body))
|
|
})
|
|
|
|
it('updates a download task', async () => {
|
|
const payload = { id: 'task-1', status: 'running' }
|
|
const body = { status: 'running' as const, downloadedBytes: 1024 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await updateDownloadTask('task-1', body)
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/download-tasks/task-1')
|
|
expect(init.method).toBe('PATCH')
|
|
expect(init.body).toBe(JSON.stringify(body))
|
|
})
|
|
|
|
it('lists admin downloaders', async () => {
|
|
const payload = { items: [{ id: 'downloader-1', name: 'vps-1' }], total: 1 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listDownloaders()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/downloaders')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('updates an admin downloader', async () => {
|
|
const payload = { id: 'downloader-1', enabled: false }
|
|
const body = { enabled: false }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await updateDownloader('downloader-1', body)
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/downloaders/downloader-1')
|
|
expect(init.method).toBe('PATCH')
|
|
expect(init.body).toBe(JSON.stringify(body))
|
|
})
|
|
|
|
it('deletes an admin downloader', async () => {
|
|
const payload = { id: 'downloader-1', deleted: true }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await deleteDownloader('downloader-1')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/downloaders/downloader-1')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('sends downloader heartbeat', async () => {
|
|
const payload = { id: 'downloader-1', status: 'online' }
|
|
const body = {
|
|
version: '0.1.0',
|
|
hostname: 'vps-1',
|
|
platform: 'linux',
|
|
arch: 'amd64',
|
|
engine: 'aria2' as const,
|
|
capabilities: ['http'],
|
|
maxConcurrentTasks: 2,
|
|
currentTasks: 1,
|
|
downloadBps: 2048,
|
|
uploadBps: 512,
|
|
freeDiskBytes: 1024,
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await sendDownloaderHeartbeat(body)
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/downloader/heartbeat')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(JSON.stringify(body))
|
|
})
|
|
|
|
it('throws ApiError on download task failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'credits exhausted' }, false, 402))
|
|
|
|
await expect(updateDownloadTask('task-1', { downloadedBytes: 2048 })).rejects.toThrow('credits exhausted')
|
|
})
|
|
})
|
|
|
|
describe('background jobs api', () => {
|
|
const job = {
|
|
id: 'job-1',
|
|
orgId: 'org-1',
|
|
userId: 'user-1',
|
|
type: 'archive_compress',
|
|
status: 'completed',
|
|
targetFolder: null,
|
|
targetPath: null,
|
|
metadata: null,
|
|
progress: {
|
|
inputBytes: 10,
|
|
outputBytes: 20,
|
|
processedBytes: 10,
|
|
fileCount: 1,
|
|
currentFilename: null,
|
|
},
|
|
errorMessage: null,
|
|
resultMetadata: { outputName: 'files.zip' },
|
|
retryable: false,
|
|
cancelable: false,
|
|
retriedFromJobId: null,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
startedAt: '2026-01-01T00:00:01.000Z',
|
|
finishedAt: '2026-01-01T00:00:02.000Z',
|
|
}
|
|
|
|
it('lists jobs with status, type, and pagination query', async () => {
|
|
const payload = { items: [job], total: 1, page: 2, pageSize: 10 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listBackgroundJobs({
|
|
status: 'failed',
|
|
type: 'archive_extract',
|
|
page: 2,
|
|
pageSize: 10,
|
|
})
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/background-jobs?')
|
|
expect(url).toContain('status=failed')
|
|
expect(url).toContain('type=archive_extract')
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=10')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('creates a background job with JSON payload', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(job, true, 201))
|
|
|
|
const result = await createBackgroundJob({ type: 'archive_compress', matterIds: ['file-1', 'folder-1'] })
|
|
|
|
expect(result).toEqual(job)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/background-jobs')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(JSON.stringify({ type: 'archive_compress', matterIds: ['file-1', 'folder-1'] }))
|
|
})
|
|
|
|
it('gets a background job by id', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(job))
|
|
|
|
const result = await getBackgroundJob('job-1')
|
|
|
|
expect(result).toEqual(job)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/background-jobs/job-1')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('cancels a background job', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ ...job, status: 'canceled' }))
|
|
|
|
const result = await cancelBackgroundJob('job-1')
|
|
|
|
expect(result.status).toBe('canceled')
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/background-jobs/job-1/cancel')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('retries a failed background job', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ ...job, id: 'job-2' }, true, 201))
|
|
|
|
const result = await retryBackgroundJob('job-1')
|
|
|
|
expect(result.id).toBe('job-2')
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/background-jobs/job-1/retry')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('throws ApiError for background job failures', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Background job cannot be retried' }, false, 409))
|
|
|
|
await expect(retryBackgroundJob('job-1')).rejects.toMatchObject({
|
|
name: 'ApiError',
|
|
status: 409,
|
|
body: { error: 'Background job cannot be retried' },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('listStorages', () => {
|
|
it('fetches storages list', async () => {
|
|
const payload = { items: [{ id: 's1', name: 'main' }], total: 1 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listStorages()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/admin/storages')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(listStorages()).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('createStorage', () => {
|
|
const validInput = {
|
|
title: 'minio',
|
|
mode: 'private' as const,
|
|
bucket: 'files',
|
|
endpoint: 'https://minio.example.com',
|
|
region: 'us-east-1',
|
|
accessKey: 'key',
|
|
secretKey: 'secret',
|
|
capacity: 1073741824,
|
|
}
|
|
|
|
it('posts storage data and returns created storage', async () => {
|
|
const storage = { id: 's1', title: 'minio', bucket: 'files' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
|
|
|
|
const result = await createStorage(validInput)
|
|
|
|
expect(result).toEqual(storage)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/storages')
|
|
expect(init.method).toBe('POST')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ title: 'minio', bucket: 'files' })
|
|
const headers =
|
|
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
|
|
expect(headers.get('Content-Type')).toContain('application/json')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'conflict' }, false, 409))
|
|
|
|
await expect(createStorage(validInput)).rejects.toThrow('conflict')
|
|
})
|
|
})
|
|
|
|
describe('getStorage', () => {
|
|
it('fetches storage by id', async () => {
|
|
const storage = { id: 's1', name: 'minio' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
|
|
|
|
const result = await getStorage('s1')
|
|
|
|
expect(result).toEqual(storage)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/admin/storages/s1')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(getStorage('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('updateStorage', () => {
|
|
it('puts updated storage data and returns updated storage', async () => {
|
|
const storage = { id: 's1', title: 'updated-minio' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
|
|
|
|
const result = await updateStorage('s1', { title: 'updated-minio' })
|
|
|
|
expect(result).toEqual(storage)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/storages/s1')
|
|
expect(init.method).toBe('PUT')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ title: 'updated-minio' })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(updateStorage('s1', { title: 'x' })).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('deleteStorage', () => {
|
|
it('sends DELETE request and returns deleted flag', async () => {
|
|
const payload = { id: 's1', deleted: true }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await deleteStorage('s1')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/storages/s1')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(deleteStorage('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('listUsers', () => {
|
|
it('fetches users with page and pageSize query params', async () => {
|
|
const payload = { items: [{ id: 'u1', name: 'Alice' }], total: 1 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listUsers(2, 20)
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/admin/users')
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=20')
|
|
})
|
|
|
|
it('includes search query when provided', async () => {
|
|
const payload = { items: [], total: 0 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await listUsers(1, 20, 'alice')
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('search=alice')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(listUsers(1, 10)).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('updateUserStatus', () => {
|
|
it('patches user and returns updated user', async () => {
|
|
const updated = { id: 'u1', status: 'active' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(updated))
|
|
|
|
const result = await updateUserStatus('u1', 'active')
|
|
|
|
expect(result).toEqual(updated)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/users/u1')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ status: 'active' })
|
|
})
|
|
|
|
it('sends disabled status correctly', async () => {
|
|
const updated = { id: 'u1', status: 'disabled' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(updated))
|
|
|
|
await updateUserStatus('u1', 'disabled')
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ status: 'disabled' })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(updateUserStatus('missing', 'active')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('deleteUser', () => {
|
|
it('sends DELETE request and returns deleted flag', async () => {
|
|
const payload = { id: 'u1', deleted: true }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await deleteUser('u1')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/users/u1')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(deleteUser('u1')).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('batchUpdateUserStatus', () => {
|
|
it('patches batch endpoint with disable action', async () => {
|
|
const payload = { updated: 2, ids: ['u1', 'u2'], status: 'disabled' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await batchUpdateUserStatus(['u1', 'u2'], 'disabled')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/users/batch')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'disable', ids: ['u1', 'u2'] })
|
|
})
|
|
|
|
it('patches batch endpoint with enable action', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ updated: 1, ids: ['u1'], status: 'active' }))
|
|
|
|
await batchUpdateUserStatus(['u1'], 'active')
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'enable', ids: ['u1'] })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(batchUpdateUserStatus(['missing'], 'disabled')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('batchDeleteUsers', () => {
|
|
it('deletes users through batch endpoint', async () => {
|
|
const payload = { deleted: 2, ids: ['u1', 'u2'] }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await batchDeleteUsers(['u1', 'u2'])
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/users/batch')
|
|
expect(init.method).toBe('DELETE')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ ids: ['u1', 'u2'] })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(batchDeleteUsers(['u1'])).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('user entitlements', () => {
|
|
it('lists user quota entitlements', async () => {
|
|
const payload = {
|
|
orgId: 'org-1',
|
|
items: [
|
|
{
|
|
id: 'ent-1',
|
|
orgId: 'org-1',
|
|
resourceType: 'storage',
|
|
entitlementType: 'plan',
|
|
source: 'free_plan',
|
|
sourceId: 'free_plan:org-1',
|
|
bytes: 1024,
|
|
startsAt: '2026-05-01T00:00:00.000Z',
|
|
expiresAt: null,
|
|
status: 'active',
|
|
metadata: null,
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
},
|
|
],
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listUserEntitlements('u1')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/users/u1/entitlements')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('grants a user quota entitlement', async () => {
|
|
const payload = {
|
|
orgId: 'org-1',
|
|
entitlement: {
|
|
id: 'ent-2',
|
|
orgId: 'org-1',
|
|
resourceType: 'storage',
|
|
entitlementType: 'grant',
|
|
source: 'admin_grant',
|
|
sourceId: 'admin_grant:1',
|
|
bytes: 2048,
|
|
startsAt: '2026-05-01T00:00:00.000Z',
|
|
expiresAt: null,
|
|
status: 'active',
|
|
metadata: null,
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
},
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await grantUserEntitlement('u1', { resourceType: 'storage', bytes: 2048, expiresAt: null })
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/users/u1/entitlements')
|
|
expect(init.method).toBe('POST')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ resourceType: 'storage', bytes: 2048, expiresAt: null })
|
|
})
|
|
|
|
it('throws on entitlement error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(listUserEntitlements('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('listQuotas', () => {
|
|
it('fetches quotas list', async () => {
|
|
const payload = {
|
|
items: [
|
|
{
|
|
orgId: 'org1',
|
|
baseQuota: 1024,
|
|
entitlementQuota: 0,
|
|
quota: 1024,
|
|
used: 512,
|
|
baseTrafficQuota: 2048,
|
|
entitlementTrafficQuota: 0,
|
|
trafficQuota: 2048,
|
|
trafficUsed: 256,
|
|
trafficPeriod: '2026-05',
|
|
storagePlanName: null,
|
|
storageExtraNames: [],
|
|
trafficPlanName: null,
|
|
trafficExtraNames: [],
|
|
},
|
|
],
|
|
total: 1,
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listQuotas()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/admin/quotas')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(listQuotas()).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('getUserQuota', () => {
|
|
it('fetches the current user quota', async () => {
|
|
const payload = {
|
|
orgId: 'org1',
|
|
baseQuota: 1024,
|
|
entitlementQuota: 0,
|
|
quota: 1024,
|
|
used: 256,
|
|
baseTrafficQuota: 2048,
|
|
entitlementTrafficQuota: 0,
|
|
trafficQuota: 2048,
|
|
trafficUsed: 512,
|
|
trafficPeriod: '2026-05',
|
|
storagePlanName: null,
|
|
storageExtraNames: [],
|
|
trafficPlanName: null,
|
|
trafficExtraNames: [],
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getUserQuota()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/quotas/me')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
|
|
|
await expect(getUserQuota()).rejects.toThrow('unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('listSystemOptions', () => {
|
|
it('fetches all system options', async () => {
|
|
const payload = { items: [{ key: 'site_name', value: 'ZPan', public: true }], total: 1 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listSystemOptions()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/system/options')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(listSystemOptions()).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('getSystemOption', () => {
|
|
it('fetches a single system option by key', async () => {
|
|
const option = { key: 'site_name', value: 'ZPan', public: true }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(option))
|
|
|
|
const result = await getSystemOption('site_name')
|
|
|
|
expect(result).toEqual(option)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/system/options/site_name')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(getSystemOption('missing_key')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('setSystemOption', () => {
|
|
it('puts option with value only when isPublic is not provided', async () => {
|
|
const option = { key: 'site_name', value: 'MyZPan', public: false }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(option))
|
|
|
|
const result = await setSystemOption('site_name', 'MyZPan')
|
|
|
|
expect(result).toEqual(option)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/system/options/site_name')
|
|
expect(init.method).toBe('PUT')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toEqual({ value: 'MyZPan' })
|
|
expect(body.public).toBeUndefined()
|
|
})
|
|
|
|
it('puts option with value and public=true when isPublic is true', async () => {
|
|
const option = { key: 'site_name', value: 'MyZPan', public: true }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(option))
|
|
|
|
await setSystemOption('site_name', 'MyZPan', true)
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toEqual({ value: 'MyZPan', public: true })
|
|
})
|
|
|
|
it('puts option with value and public=false when isPublic is false', async () => {
|
|
const option = { key: 'site_name', value: 'MyZPan', public: false }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(option))
|
|
|
|
await setSystemOption('site_name', 'MyZPan', false)
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toEqual({ value: 'MyZPan', public: false })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(setSystemOption('key', 'val')).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('getSession', () => {
|
|
it('fetches session from /api/auth/get-session with credentials include', async () => {
|
|
const session = { session: { id: 'sess1' }, user: { id: 'u1', email: 'a@b.com' } }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(session))
|
|
|
|
const result = await getSession()
|
|
|
|
expect(result).toEqual(session)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/auth/get-session')
|
|
expect(init.credentials).toBe('include')
|
|
})
|
|
|
|
it('returns null when response is not ok', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
|
|
|
const result = await getSession()
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('trashObject', () => {
|
|
it('sends PATCH with action: trash for the given id', async () => {
|
|
const obj = { id: 'id1', status: 'trashed' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
|
|
|
|
const result = await trashObject('id1')
|
|
|
|
expect(result).toEqual(obj)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/id1')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'trash' })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(trashObject('missing')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('batchTrashObjects', () => {
|
|
it('patches batch endpoint with action: trash and returns trashed count', async () => {
|
|
const payload = { trashed: 3 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await batchTrashObjects(['id1', 'id2', 'id3'])
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/batch')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'trash', ids: ['id1', 'id2', 'id3'] })
|
|
})
|
|
|
|
it('posts an empty ids array without error', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ trashed: 0 }))
|
|
|
|
const result = await batchTrashObjects([])
|
|
|
|
expect(result).toEqual({ trashed: 0 })
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'trash', ids: [] })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(batchTrashObjects(['id1'])).rejects.toThrow('forbidden')
|
|
})
|
|
})
|
|
|
|
describe('batchMoveObjects', () => {
|
|
it('patches batch endpoint with action: move and returns moved count', async () => {
|
|
const payload = { moved: 2 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await batchMoveObjects(['id1', 'id2'], 'folder1')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/batch')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ action: 'move', ids: ['id1', 'id2'], parent: 'folder1' })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
|
|
|
await expect(batchMoveObjects(['id1'], 'missing-folder')).rejects.toThrow('not found')
|
|
})
|
|
})
|
|
|
|
describe('batchDeleteObjects', () => {
|
|
it('sends DELETE to batch endpoint and returns deleted count', async () => {
|
|
const payload = { deleted: 2 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await batchDeleteObjects(['id1', 'id2'])
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/objects/batch')
|
|
expect(init.method).toBe('DELETE')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ ids: ['id1', 'id2'] })
|
|
})
|
|
|
|
it('posts an empty ids array without error', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ deleted: 0 }))
|
|
|
|
const result = await batchDeleteObjects([])
|
|
|
|
expect(result).toEqual({ deleted: 0 })
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'server error' }, false, 500))
|
|
|
|
await expect(batchDeleteObjects(['id1'])).rejects.toThrow('server error')
|
|
})
|
|
})
|
|
|
|
describe('listAuthProviders', () => {
|
|
it('fetches auth providers list from /api/auth-providers', async () => {
|
|
const payload = {
|
|
items: [{ providerId: 'github', type: 'oauth', name: 'GitHub', icon: '' }],
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listAuthProviders()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/auth-providers')
|
|
})
|
|
|
|
it('returns items array with expected provider shape', async () => {
|
|
const provider = { providerId: 'google', type: 'oauth', name: 'Google', icon: 'google-icon' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [provider] }))
|
|
|
|
const result = await listAuthProviders()
|
|
|
|
expect(result.items).toHaveLength(1)
|
|
expect(result.items[0].providerId).toBe('google')
|
|
expect(result.items[0].name).toBe('Google')
|
|
expect(result.items[0].type).toBe('oauth')
|
|
expect(result.items[0].icon).toBe('google-icon')
|
|
})
|
|
|
|
it('returns empty items array when no providers are configured', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [] }))
|
|
|
|
const result = await listAuthProviders()
|
|
|
|
expect(result.items).toHaveLength(0)
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
|
|
|
await expect(listAuthProviders()).rejects.toThrow('forbidden')
|
|
})
|
|
|
|
it('passes credentials: include', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [] }))
|
|
|
|
await listAuthProviders()
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(init.credentials).toBe('include')
|
|
})
|
|
})
|
|
|
|
describe('getProfile', () => {
|
|
it('fetches public profile by username', async () => {
|
|
const payload = {
|
|
user: { username: 'alice', name: 'Alice', image: null },
|
|
shares: [],
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getProfile('alice')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/profiles/alice')
|
|
})
|
|
|
|
it('returns shares with download URLs', async () => {
|
|
const matter = { id: 'm1', name: 'photo.jpg', dirtype: 0, downloadUrl: 'https://s3/photo.jpg' }
|
|
const payload = {
|
|
user: { username: 'bob', name: 'Bob', image: null },
|
|
shares: [matter],
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getProfile('bob')
|
|
|
|
expect(result.shares).toHaveLength(1)
|
|
expect(result.shares[0].downloadUrl).toBe('https://s3/photo.jpg')
|
|
})
|
|
|
|
it('throws on 404 response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'User not found' }, false, 404))
|
|
|
|
await expect(getProfile('nobody')).rejects.toThrow('User not found')
|
|
})
|
|
})
|
|
|
|
describe('listNotifications', () => {
|
|
it('calls /api/notifications with default params', async () => {
|
|
const payload = { items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listNotifications()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/notifications')
|
|
expect(url).toContain('page=1')
|
|
expect(url).toContain('pageSize=20')
|
|
expect(url).toContain('unread=false')
|
|
})
|
|
|
|
it('passes page, pageSize, and unreadOnly params', async () => {
|
|
const payload = { items: [], total: 5, unreadCount: 5, page: 2, pageSize: 10 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await listNotifications(2, 10, true)
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=10')
|
|
expect(url).toContain('unread=true')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
|
|
|
await expect(listNotifications()).rejects.toThrow('unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('getUnreadCount', () => {
|
|
it('calls /api/notifications/stats and returns count', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ count: 3 }))
|
|
|
|
const result = await getUnreadCount()
|
|
|
|
expect(result).toEqual({ count: 3 })
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/notifications/stats')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
|
|
|
await expect(getUnreadCount()).rejects.toThrow('unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('markNotificationRead', () => {
|
|
it('patches /api/notifications/:id and resolves on 204', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
|
|
|
|
await expect(markNotificationRead('notif-1')).resolves.toBeUndefined()
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/notifications/notif-1')
|
|
expect(init.method).toBe('PATCH')
|
|
})
|
|
|
|
it('throws ApiError on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response)
|
|
|
|
await expect(markNotificationRead('missing')).rejects.toThrow('Not Found')
|
|
})
|
|
})
|
|
|
|
describe('markAllNotificationsRead', () => {
|
|
it('patches /api/notifications and returns count', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ count: 5 }))
|
|
|
|
const result = await markAllNotificationsRead()
|
|
|
|
expect(result).toEqual({ count: 5 })
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/notifications')
|
|
expect(init.method).toBe('PATCH')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
|
|
|
await expect(markAllNotificationsRead()).rejects.toThrow('unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('announcements api', () => {
|
|
const announcement = {
|
|
id: 'ann-1',
|
|
title: 'Maintenance',
|
|
body: 'Short outage',
|
|
status: 'published',
|
|
priority: 1,
|
|
publishedAt: '2026-05-01T00:00:00.000Z',
|
|
createdBy: 'user-1',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
}
|
|
|
|
const input = {
|
|
title: 'Maintenance',
|
|
body: 'Short outage',
|
|
status: 'published' as const,
|
|
priority: 1,
|
|
}
|
|
|
|
it('lists announcement history', async () => {
|
|
const payload = { items: [announcement], total: 1, page: 1, pageSize: 20 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listAnnouncements()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/announcements')
|
|
expect(url).toContain('page=1')
|
|
expect(url).toContain('pageSize=20')
|
|
})
|
|
|
|
it('throws ApiError when announcement history fails', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Cannot list announcements' }, false, 500))
|
|
|
|
await expect(listAnnouncements()).rejects.toThrow('Cannot list announcements')
|
|
})
|
|
|
|
it('lists active announcements with scope query', async () => {
|
|
const payload = { items: [announcement], total: 1, page: 1, pageSize: 20 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await listActiveAnnouncements()
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/announcements')
|
|
expect(url).toContain('scope=active')
|
|
})
|
|
|
|
it('throws ApiError when active announcements fail', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Cannot list active announcements' }, false, 500))
|
|
|
|
await expect(listActiveAnnouncements()).rejects.toThrow('Cannot list active announcements')
|
|
})
|
|
|
|
it('lists admin announcements with status filter', async () => {
|
|
const payload = { items: [announcement], total: 1, page: 1, pageSize: 20 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await listAdminAnnouncements(1, 20, 'published')
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/admin/announcements')
|
|
expect(url).toContain('status=published')
|
|
})
|
|
|
|
it('throws ApiError when admin announcement list fails', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(listAdminAnnouncements()).rejects.toThrow('Forbidden')
|
|
})
|
|
|
|
it('creates an announcement', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(announcement))
|
|
|
|
const result = await createAnnouncement(input)
|
|
|
|
expect(result).toEqual(announcement)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/announcements')
|
|
expect(init.method).toBe('POST')
|
|
expect(JSON.parse(init.body as string)).toEqual(input)
|
|
})
|
|
|
|
it('gets an announcement', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(announcement))
|
|
|
|
await getAnnouncement('ann-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/announcements/ann-1')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('throws ApiError when getting an announcement fails', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Announcement not found' }, false, 404))
|
|
|
|
await expect(getAnnouncement('missing')).rejects.toThrow('Announcement not found')
|
|
})
|
|
|
|
it('updates an announcement', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(announcement))
|
|
|
|
await updateAnnouncement('ann-1', input)
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/announcements/ann-1')
|
|
expect(init.method).toBe('PUT')
|
|
expect(JSON.parse(init.body as string)).toEqual(input)
|
|
})
|
|
|
|
it('throws ApiError when updating an announcement fails', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Invalid announcement' }, false, 400))
|
|
|
|
await expect(updateAnnouncement('ann-1', input)).rejects.toThrow('Invalid announcement')
|
|
})
|
|
|
|
it('deletes an announcement', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'ann-1', deleted: true }))
|
|
|
|
const result = await deleteAnnouncement('ann-1')
|
|
|
|
expect(result).toEqual({ id: 'ann-1', deleted: true })
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/admin/announcements/ann-1')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws ApiError when deleting an announcement fails', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Announcement not found' }, false, 404))
|
|
|
|
await expect(deleteAnnouncement('missing')).rejects.toThrow('Announcement not found')
|
|
})
|
|
|
|
it('throws ApiError for failed create', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Invalid announcement' }, false, 400))
|
|
|
|
await expect(createAnnouncement(input)).rejects.toThrow('Invalid announcement')
|
|
})
|
|
})
|
|
|
|
describe('listShares', () => {
|
|
it('calls /api/shares with default params', async () => {
|
|
const payload = { items: [], total: 0, page: 1, pageSize: 20 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listShares()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/shares')
|
|
expect(url).toContain('page=1')
|
|
expect(url).toContain('pageSize=20')
|
|
})
|
|
|
|
it('passes page, pageSize, and status params', async () => {
|
|
const payload = { items: [], total: 3, page: 2, pageSize: 10 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await listShares(2, 10, 'active')
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=10')
|
|
expect(url).toContain('status=active')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
|
|
|
await expect(listShares()).rejects.toThrow('unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('getShare', () => {
|
|
it('calls GET /api/shares/:token and returns share view', async () => {
|
|
const payload = {
|
|
token: 'tok123',
|
|
kind: 'landing',
|
|
status: 'active',
|
|
expiresAt: null,
|
|
downloadLimit: null,
|
|
matter: { name: 'photo.jpg', type: 'image/jpeg', size: 1024, isFolder: false },
|
|
creatorName: 'Alice',
|
|
requiresPassword: false,
|
|
expired: false,
|
|
exhausted: false,
|
|
accessibleByUser: false,
|
|
downloads: 0,
|
|
views: 1,
|
|
rootRef: 'abc',
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getShare('tok123')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/shares/tok123')
|
|
})
|
|
|
|
it('throws on 404', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Share not found or revoked' }, false, 404))
|
|
|
|
await expect(getShare('bad-token')).rejects.toThrow('Share not found or revoked')
|
|
})
|
|
|
|
it('throws on 410 (matter trashed)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'File no longer available' }, false, 410))
|
|
|
|
await expect(getShare('bad-token')).rejects.toThrow('File no longer available')
|
|
})
|
|
})
|
|
|
|
describe('deleteShare', () => {
|
|
it('calls DELETE /api/shares/:token and resolves on 204', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
|
|
|
|
await expect(deleteShare('tok123')).resolves.toBeUndefined()
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/shares/tok123')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws ApiError on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' } as Response)
|
|
|
|
await expect(deleteShare('tok123')).rejects.toThrow('Forbidden')
|
|
})
|
|
})
|
|
|
|
describe('createShare', () => {
|
|
it('posts share data to /api/shares and returns created share result', async () => {
|
|
const payload = {
|
|
token: 'tok123',
|
|
kind: 'landing' as const,
|
|
urls: { landing: 'https://zpan.io/s/tok123' },
|
|
expiresAt: null,
|
|
downloadLimit: null,
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await createShare({ matterId: 'obj-1', kind: 'landing' })
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/shares')
|
|
expect(init.method).toBe('POST')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body).toMatchObject({ matterId: 'obj-1', kind: 'landing' })
|
|
const headers =
|
|
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
|
|
expect(headers.get('Content-Type')).toContain('application/json')
|
|
})
|
|
|
|
it('throws on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
|
|
|
await expect(createShare({ matterId: 'obj-1', kind: 'landing' })).rejects.toThrow('unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('verifySharePassword', () => {
|
|
it('calls POST /api/shares/:token/sessions with password', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ ok: true }))
|
|
|
|
const result = await verifySharePassword('tok123', 'secret')
|
|
|
|
expect(result).toEqual({ ok: true })
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/shares/tok123/sessions')
|
|
expect(init.method).toBe('POST')
|
|
expect(JSON.parse(init.body as string)).toEqual({ password: 'secret' })
|
|
})
|
|
|
|
it('throws ApiError on 403 (wrong password)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Invalid password' }, false, 403))
|
|
|
|
await expect(verifySharePassword('tok123', 'wrong')).rejects.toThrow('Invalid password')
|
|
})
|
|
})
|
|
|
|
describe('listShareObjects', () => {
|
|
it('calls GET /api/shares/:token/objects with default params', async () => {
|
|
const payload = { items: [], total: 0, page: 1, pageSize: 50, breadcrumb: [] }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listShareObjects('tok123')
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/shares/tok123/objects')
|
|
expect(url).toContain('page=1')
|
|
expect(url).toContain('pageSize=50')
|
|
})
|
|
|
|
it('passes custom parent, page, and pageSize', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({ items: [], total: 0, page: 2, pageSize: 10, breadcrumb: [] }),
|
|
)
|
|
|
|
await listShareObjects('tok123', 'Reports', 2, 10)
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('parent=Reports')
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=10')
|
|
})
|
|
|
|
it('throws ApiError on 401 (password required)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Password required' }, false, 401))
|
|
|
|
await expect(listShareObjects('tok123')).rejects.toThrow('Password required')
|
|
})
|
|
})
|
|
|
|
describe('buildShareObjectUrl', () => {
|
|
it('returns the canonical share object download URL', () => {
|
|
expect(buildShareObjectUrl('tok123', 'refABC')).toBe('/api/shares/tok123/objects/refABC')
|
|
})
|
|
})
|
|
|
|
describe('saveShareToDrive', () => {
|
|
it('calls POST /api/shares/:token/objects with targetOrgId and targetParent', async () => {
|
|
const payload = { saved: [{ id: 'obj-1', name: 'photo.jpg' }], skipped: [] }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
|
|
|
|
const result = await saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: 'Docs' })
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/shares/tok123/objects')
|
|
expect(init.method).toBe('POST')
|
|
expect(JSON.parse(init.body as string)).toEqual({ targetOrgId: 'org-1', targetParent: 'Docs' })
|
|
})
|
|
|
|
it('throws ApiError with QUOTA_EXCEEDED code on 400', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, false, 400),
|
|
)
|
|
|
|
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toThrow(
|
|
'Quota exceeded',
|
|
)
|
|
})
|
|
|
|
it('throws ApiError on 401 (password required)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({ error: 'Authentication required for password-protected share' }, false, 401),
|
|
)
|
|
|
|
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toThrow(
|
|
'Authentication required',
|
|
)
|
|
})
|
|
|
|
it('throws ApiError on 410 (share gone)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Share target has been deleted' }, false, 410))
|
|
|
|
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toThrow(
|
|
'Share target has been deleted',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('getIhostConfig', () => {
|
|
it('calls GET /api/ihost/config and returns config when enabled', async () => {
|
|
const payload = {
|
|
enabled: true,
|
|
customDomain: null,
|
|
domainVerifiedAt: null,
|
|
domainStatus: 'none',
|
|
dnsInstructions: null,
|
|
refererAllowlist: null,
|
|
createdAt: 1700000000000,
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getIhostConfig()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/ihost/config')
|
|
})
|
|
|
|
it('returns null when feature is not enabled', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null))
|
|
|
|
const result = await getIhostConfig()
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('passes credentials: include', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null))
|
|
|
|
await getIhostConfig()
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(init.credentials).toBe('include')
|
|
})
|
|
|
|
it('throws ApiError on 401', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Unauthorized' }, false, 401))
|
|
|
|
await expect(getIhostConfig()).rejects.toThrow('Unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('enableIhostFeature', () => {
|
|
it('calls PUT /api/ihost/config with enabled: true and returns config', async () => {
|
|
const payload = {
|
|
enabled: true,
|
|
customDomain: null,
|
|
domainVerifiedAt: null,
|
|
domainStatus: 'none',
|
|
dnsInstructions: null,
|
|
refererAllowlist: null,
|
|
createdAt: 1700000000000,
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 200))
|
|
|
|
const result = await enableIhostFeature()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/ihost/config')
|
|
expect(init.method).toBe('PUT')
|
|
expect(JSON.parse(init.body as string)).toEqual({ enabled: true })
|
|
})
|
|
|
|
it('passes credentials: include', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({
|
|
enabled: true,
|
|
customDomain: null,
|
|
domainVerifiedAt: null,
|
|
domainStatus: 'none',
|
|
dnsInstructions: null,
|
|
refererAllowlist: null,
|
|
createdAt: 1700000000000,
|
|
}),
|
|
)
|
|
|
|
await enableIhostFeature()
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(init.credentials).toBe('include')
|
|
})
|
|
|
|
it('throws ApiError on 403 (insufficient role)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(enableIhostFeature()).rejects.toThrow('Forbidden')
|
|
})
|
|
|
|
it('throws ApiError on 409 (domain already in use)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Domain already in use' }, false, 409))
|
|
|
|
await expect(enableIhostFeature()).rejects.toThrow('Domain already in use')
|
|
})
|
|
})
|
|
|
|
describe('updateIhostConfig', () => {
|
|
const baseConfig = {
|
|
enabled: true,
|
|
customDomain: null,
|
|
domainVerifiedAt: null,
|
|
domainStatus: 'none',
|
|
dnsInstructions: null,
|
|
refererAllowlist: null,
|
|
createdAt: 1700000000000,
|
|
}
|
|
|
|
it('sends PUT with enabled:true and customDomain', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(baseConfig))
|
|
|
|
await updateIhostConfig({ customDomain: 'img.example.com' })
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/ihost/config')
|
|
expect(init.method).toBe('PUT')
|
|
const body = JSON.parse(init.body as string)
|
|
expect(body.enabled).toBe(true)
|
|
expect(body.customDomain).toBe('img.example.com')
|
|
})
|
|
|
|
it('sends PUT with refererAllowlist', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(baseConfig))
|
|
|
|
await updateIhostConfig({ refererAllowlist: ['https://example.com'] })
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = JSON.parse(init.body as string)
|
|
expect(body.refererAllowlist).toEqual(['https://example.com'])
|
|
})
|
|
|
|
it('resolves with updated config on success', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(baseConfig))
|
|
|
|
const result = await updateIhostConfig({})
|
|
|
|
expect(result).toEqual(baseConfig)
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(updateIhostConfig({})).rejects.toThrow('Forbidden')
|
|
})
|
|
})
|
|
|
|
describe('deleteIhostConfig', () => {
|
|
it('sends DELETE to /api/ihost/config', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
|
|
|
|
await deleteIhostConfig()
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/ihost/config')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('resolves without error on 204', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
|
|
|
|
await expect(deleteIhostConfig()).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('throws ApiError on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(deleteIhostConfig()).rejects.toBeInstanceOf(Error)
|
|
})
|
|
})
|
|
|
|
describe('listIhostApiKeys', () => {
|
|
const sampleKey = {
|
|
id: 'key-1',
|
|
name: 'My Key',
|
|
start: 'abc',
|
|
prefix: null,
|
|
createdAt: '2024-01-01T00:00:00.000Z',
|
|
lastRequest: null,
|
|
permissions: { 'image-hosting': ['upload'] },
|
|
referenceId: 'org-1',
|
|
enabled: true,
|
|
}
|
|
|
|
it('calls GET /api/auth/api-key/list with organizationId', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ apiKeys: [sampleKey] }))
|
|
|
|
await listIhostApiKeys('org-1')
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/auth/api-key/list')
|
|
expect(url).toContain('organizationId=org-1')
|
|
})
|
|
|
|
it('filters to image-hosting:upload permission only', async () => {
|
|
const otherKey = {
|
|
...sampleKey,
|
|
id: 'key-2',
|
|
permissions: { 'other-scope': ['read'] },
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ apiKeys: [sampleKey, otherKey] }))
|
|
|
|
const result = await listIhostApiKeys('org-1')
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('key-1')
|
|
})
|
|
|
|
it('returns empty array when no matching keys', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ apiKeys: [] }))
|
|
|
|
const result = await listIhostApiKeys('org-1')
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Unauthorized' }, false, 401))
|
|
|
|
await expect(listIhostApiKeys('org-1')).rejects.toThrow('Unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('createIhostApiKey', () => {
|
|
const createdKey = {
|
|
id: 'new-key',
|
|
key: 'sk_live_abc123xyz',
|
|
name: 'Test Key',
|
|
start: 'sk_',
|
|
prefix: null,
|
|
createdAt: '2024-01-01T00:00:00.000Z',
|
|
lastRequest: null,
|
|
permissions: { 'image-hosting': ['upload'] },
|
|
referenceId: 'org-1',
|
|
enabled: true,
|
|
}
|
|
|
|
it('calls POST /api/auth/api-key/create', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(createdKey))
|
|
|
|
await createIhostApiKey('org-1', 'Test Key')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/auth/api-key/create')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('sends organizationId and name (permissions set by server defaults)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(createdKey))
|
|
|
|
await createIhostApiKey('org-1', 'Test Key')
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = JSON.parse(init.body as string)
|
|
expect(body.organizationId).toBe('org-1')
|
|
expect(body.name).toBe('Test Key')
|
|
expect(body.permissions).toBeUndefined()
|
|
})
|
|
|
|
it('resolves with the full key on success', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(createdKey))
|
|
|
|
const result = await createIhostApiKey('org-1', 'Test Key')
|
|
|
|
expect(result.key).toBe('sk_live_abc123xyz')
|
|
expect(result.id).toBe('new-key')
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Name required' }, false, 400))
|
|
|
|
await expect(createIhostApiKey('org-1', '')).rejects.toThrow('Name required')
|
|
})
|
|
})
|
|
|
|
describe('revokeIhostApiKey', () => {
|
|
it('calls POST /api/auth/api-key/delete', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
|
|
|
|
await revokeIhostApiKey('key-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/auth/api-key/delete')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('sends keyId in request body', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
|
|
|
|
await revokeIhostApiKey('key-1')
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const body = JSON.parse(init.body as string)
|
|
expect(body.keyId).toBe('key-1')
|
|
})
|
|
|
|
it('resolves with success response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
|
|
|
|
const result = await revokeIhostApiKey('key-1')
|
|
|
|
expect(result).toEqual({ success: true })
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Key not found' }, false, 404))
|
|
|
|
await expect(revokeIhostApiKey('key-1')).rejects.toThrow('Key not found')
|
|
})
|
|
})
|
|
|
|
describe('WebDAV app passwords', () => {
|
|
const samplePassword = {
|
|
id: 'webdav-key-1',
|
|
name: 'Finder',
|
|
start: 'zpan',
|
|
prefix: null,
|
|
createdAt: '2024-01-01T00:00:00.000Z',
|
|
lastRequest: null,
|
|
permissions: { webdav: ['read', 'write'] },
|
|
referenceId: 'user-1',
|
|
enabled: true,
|
|
}
|
|
|
|
it('lists only webdav app passwords', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({
|
|
apiKeys: [samplePassword, { ...samplePassword, id: 'other', permissions: { other: ['read'] } }],
|
|
}),
|
|
)
|
|
|
|
const result = await listWebDavAppPasswords()
|
|
|
|
expect(result).toEqual([samplePassword])
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/auth/api-key/list?configId=webdav')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('creates a webdav app password with configId', async () => {
|
|
const created = { ...samplePassword, key: 'webdav-secret' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(created))
|
|
|
|
const result = await createWebDavAppPassword('Finder')
|
|
|
|
expect(result).toEqual(created)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/auth/api-key/create')
|
|
expect(init.method).toBe('POST')
|
|
expect(JSON.parse(init.body as string)).toEqual({ configId: 'webdav', name: 'Finder' })
|
|
})
|
|
|
|
it('revokes a webdav app password with configId', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
|
|
|
|
const result = await revokeWebDavAppPassword('webdav-key-1')
|
|
|
|
expect(result).toEqual({ success: true })
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/auth/api-key/delete')
|
|
expect(init.method).toBe('POST')
|
|
expect(JSON.parse(init.body as string)).toEqual({ configId: 'webdav', keyId: 'webdav-key-1' })
|
|
})
|
|
|
|
it('throws ApiError on webdav app password failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Unauthorized' }, false, 401))
|
|
|
|
await expect(listWebDavAppPasswords()).rejects.toThrow('Unauthorized')
|
|
})
|
|
})
|
|
|
|
describe('listIhostImages', () => {
|
|
it('calls GET /api/ihost/images', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], nextCursor: null }))
|
|
|
|
await listIhostImages()
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/ihost/images')
|
|
})
|
|
|
|
it('passes pathPrefix, cursor, and limit as query params', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], nextCursor: null }))
|
|
|
|
await listIhostImages({ pathPrefix: 'foo/', cursor: 'abc', limit: 20 })
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('pathPrefix=foo%2F')
|
|
expect(url).toContain('cursor=abc')
|
|
expect(url).toContain('limit=20')
|
|
})
|
|
|
|
it('resolves with items and nextCursor', async () => {
|
|
const payload = { items: [{ id: 'img-1' }], nextCursor: 'next' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listIhostImages()
|
|
|
|
expect(result).toEqual(payload)
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(listIhostImages()).rejects.toThrow('Forbidden')
|
|
})
|
|
})
|
|
|
|
describe('createIhostImagePresign', () => {
|
|
it('calls POST /api/ihost/images/presign with JSON body', async () => {
|
|
const draft = {
|
|
id: 'd1',
|
|
token: 'ih_abc',
|
|
path: 'foo/bar.png',
|
|
uploadUrl: 'https://s3/...',
|
|
storageKey: 'ih/...',
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(draft, true, 201))
|
|
|
|
await createIhostImagePresign({ path: 'foo/bar.png', mime: 'image/png', size: 1024 })
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/ihost/images/presign')
|
|
expect(init.method).toBe('POST')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body?.path).toBe('foo/bar.png')
|
|
expect(body?.mime).toBe('image/png')
|
|
expect(body?.size).toBe(1024)
|
|
})
|
|
|
|
it('resolves with draft object', async () => {
|
|
const draft = {
|
|
id: 'd1',
|
|
token: 'ih_abc',
|
|
path: 'foo/bar.png',
|
|
uploadUrl: 'https://s3/...',
|
|
storageKey: 'ih/...',
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(draft, true, 201))
|
|
|
|
const result = await createIhostImagePresign({ path: 'foo/bar.png', mime: 'image/png', size: 1024 })
|
|
|
|
expect(result).toEqual(draft)
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Quota exceeded' }, false, 422))
|
|
|
|
await expect(createIhostImagePresign({ path: 'a.png', mime: 'image/png', size: 100 })).rejects.toThrow(
|
|
'Quota exceeded',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('confirmIhostImage', () => {
|
|
it('calls PATCH /api/ihost/images/:id with action confirm', async () => {
|
|
const image = { id: 'img-1', status: 'active' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(image))
|
|
|
|
await confirmIhostImage('img-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/ihost/images/img-1')
|
|
expect(init.method).toBe('PATCH')
|
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
|
expect(body?.action).toBe('confirm')
|
|
})
|
|
|
|
it('resolves with confirmed image', async () => {
|
|
const image = { id: 'img-1', status: 'active', token: 'ih_x' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(image))
|
|
|
|
const result = await confirmIhostImage('img-1')
|
|
|
|
expect(result).toEqual(image)
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not found' }, false, 404))
|
|
|
|
await expect(confirmIhostImage('bad-id')).rejects.toThrow('Not found')
|
|
})
|
|
})
|
|
|
|
describe('deleteIhostImage', () => {
|
|
it('calls DELETE /api/ihost/images/:id', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204, json: async () => null } as unknown as Response)
|
|
|
|
await deleteIhostImage('img-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/ihost/images/img-1')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not found' }, false, 404))
|
|
|
|
await expect(deleteIhostImage('bad-id')).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('uploadAvatar', () => {
|
|
it('PUTs multipart/form-data to /api/me/avatar with file field', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'https://example.com/a.png' }, true, 200))
|
|
const file = new File(['x'], 'a.png', { type: 'image/png' })
|
|
|
|
await uploadAvatar(file)
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/me/avatar')
|
|
expect(init.method).toBe('PUT')
|
|
expect(init.body).toBeInstanceOf(FormData)
|
|
expect((init.body as FormData).get('file')).toBe(file)
|
|
})
|
|
|
|
it('resolves with the new URL', async () => {
|
|
const payload = { url: 'https://example.com/a.png' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 200))
|
|
const file = new File(['x'], 'a.png', { type: 'image/png' })
|
|
|
|
const result = await uploadAvatar(file)
|
|
expect(result).toEqual(payload)
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'File too large' }, false, 413))
|
|
const file = new File(['x'], 'a.png', { type: 'image/png' })
|
|
|
|
await expect(uploadAvatar(file)).rejects.toThrow('File too large')
|
|
})
|
|
})
|
|
|
|
describe('deleteAvatar', () => {
|
|
it('DELETEs /api/me/avatar', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ ok: true }, true, 200))
|
|
await deleteAvatar()
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/me/avatar')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Unauthorized' }, false, 401))
|
|
await expect(deleteAvatar()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('uploadTeamLogo', () => {
|
|
it('PUTs multipart to /api/teams/:id/logo with file field', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'https://example.com/l.jpg' }, true, 200))
|
|
const file = new File(['x'], 'l.jpg', { type: 'image/jpeg' })
|
|
|
|
await uploadTeamLogo('org-1', file)
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/teams/org-1/logo')
|
|
expect(init.method).toBe('PUT')
|
|
expect((init.body as FormData).get('file')).toBe(file)
|
|
})
|
|
|
|
it('URL-encodes the teamId', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'x' }, true, 200))
|
|
const file = new File(['x'], 'l.png', { type: 'image/png' })
|
|
|
|
await uploadTeamLogo('org with/special', file)
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/teams/org%20with%2Fspecial/logo')
|
|
})
|
|
|
|
it('throws ApiError on 403', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
const file = new File(['x'], 'l.png', { type: 'image/png' })
|
|
|
|
await expect(uploadTeamLogo('org-1', file)).rejects.toThrow('Forbidden')
|
|
})
|
|
})
|
|
|
|
describe('deleteTeamLogo', () => {
|
|
it('DELETEs /api/teams/:id/logo', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ ok: true }, true, 200))
|
|
await deleteTeamLogo('org-1')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/teams/org-1/logo')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
await expect(deleteTeamLogo('org-1')).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('getLicensingStatus', () => {
|
|
it('calls the correct endpoint', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ bound: false }))
|
|
|
|
await getLicensingStatus()
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/licensing/status')
|
|
})
|
|
|
|
it('returns unbound state', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ bound: false }))
|
|
|
|
const result = await getLicensingStatus()
|
|
|
|
expect(result).toEqual({ bound: false })
|
|
})
|
|
|
|
it('returns full bound state', async () => {
|
|
const state = {
|
|
bound: true,
|
|
account_email: 'user@example.com',
|
|
plan: 'pro',
|
|
features: ['white_label'],
|
|
expires_at: 9999999999,
|
|
last_refresh_at: 1000000000,
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(state))
|
|
|
|
const result = await getLicensingStatus()
|
|
|
|
expect(result).toEqual(state)
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Internal Server Error' }, false, 500))
|
|
|
|
await expect(getLicensingStatus()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('connectCloud', () => {
|
|
it('calls the correct endpoint with POST', async () => {
|
|
const payload = {
|
|
code: 'ABC-123',
|
|
pairingUrl: 'https://cloud.zpan.space/pair',
|
|
expiresAt: '2026-01-01T00:00:00Z',
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await connectCloud()
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/licensing/pair')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('returns pairing info', async () => {
|
|
const payload = {
|
|
code: 'XYZ-789',
|
|
pairingUrl: 'https://cloud.zpan.space/pair',
|
|
expiresAt: '2026-01-01T00:00:00Z',
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await connectCloud()
|
|
|
|
expect(result).toEqual(payload)
|
|
})
|
|
|
|
it('throws ApiError on non-admin', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(connectCloud()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('pollPairing', () => {
|
|
it('calls the correct endpoint with code', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ status: 'pending' }))
|
|
|
|
await pollPairing('ABC-123')
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/licensing/pair/ABC-123/poll')
|
|
})
|
|
|
|
it('returns pending status', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ status: 'pending' }))
|
|
|
|
const result = await pollPairing('CODE-1')
|
|
|
|
expect(result.status).toBe('pending')
|
|
})
|
|
|
|
it('returns approved status with plan', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ status: 'approved', plan: 'pro' }))
|
|
|
|
const result = await pollPairing('CODE-2')
|
|
|
|
expect(result.status).toBe('approved')
|
|
expect(result.plan).toBe('pro')
|
|
})
|
|
|
|
it('throws ApiError on error response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not Found' }, false, 404))
|
|
|
|
await expect(pollPairing('BAD-CODE')).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('refreshLicense', () => {
|
|
it('calls the correct endpoint with POST', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true, last_refresh_at: 1000000000 }))
|
|
|
|
await refreshLicense()
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/licensing/refresh')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('returns success with last_refresh_at', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true, last_refresh_at: 1745000000 }))
|
|
|
|
const result = await refreshLicense()
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.last_refresh_at).toBe(1745000000)
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(refreshLicense()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('disconnectCloud', () => {
|
|
it('calls the correct endpoint with DELETE', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ deleted: true }))
|
|
|
|
await disconnectCloud()
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/licensing/binding')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('returns deleted: true on success', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ deleted: true }))
|
|
|
|
const result = await disconnectCloud()
|
|
|
|
expect(result).toEqual({ deleted: true })
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(disconnectCloud()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('getBranding', () => {
|
|
it('calls GET /api/branding and returns config', async () => {
|
|
const payload = {
|
|
logo_url: null,
|
|
favicon_url: null,
|
|
wordmark_text: null,
|
|
hide_powered_by: false,
|
|
theme: { mode: 'preset', preset: 'default', custom: null, configured: false },
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getBranding()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/branding')
|
|
})
|
|
|
|
it('resolves with stored branding values', async () => {
|
|
const payload = {
|
|
logo_url: 'https://cdn.example.com/logo.svg',
|
|
favicon_url: null,
|
|
wordmark_text: 'MyCloud',
|
|
hide_powered_by: true,
|
|
theme: { mode: 'preset', preset: 'forest', custom: null, configured: true },
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getBranding()
|
|
|
|
expect(result.logo_url).toBe('https://cdn.example.com/logo.svg')
|
|
expect(result.wordmark_text).toBe('MyCloud')
|
|
expect(result.hide_powered_by).toBe(true)
|
|
})
|
|
|
|
it('throws ApiError on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Internal error' }, false, 500))
|
|
|
|
await expect(getBranding()).rejects.toThrow('Internal error')
|
|
})
|
|
})
|
|
|
|
describe('saveBranding', () => {
|
|
it('sends PUT /api/admin/branding as multipart', async () => {
|
|
const payload = {
|
|
logo_url: null,
|
|
favicon_url: null,
|
|
wordmark_text: 'MyCloud',
|
|
hide_powered_by: false,
|
|
theme: { mode: 'preset', preset: 'default', custom: null, configured: false },
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await saveBranding({ wordmark_text: 'MyCloud' })
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/branding')
|
|
expect(init.method).toBe('PUT')
|
|
expect(init.body).toBeInstanceOf(FormData)
|
|
})
|
|
|
|
it('includes theme fields in FormData when provided', async () => {
|
|
const payload = {
|
|
logo_url: null,
|
|
favicon_url: null,
|
|
wordmark_text: null,
|
|
hide_powered_by: false,
|
|
theme: {
|
|
mode: 'custom',
|
|
preset: 'rose',
|
|
configured: true,
|
|
custom: {
|
|
primary_color: '#123456',
|
|
primary_foreground: '#ffffff',
|
|
canvas_color: '#f8fafc',
|
|
sidebar_accent_color: '#ffe4e6',
|
|
ring_color: '#0f172a',
|
|
},
|
|
},
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
await saveBranding({
|
|
theme_mode: 'custom',
|
|
theme_preset: 'rose',
|
|
theme_custom: payload.theme.custom,
|
|
})
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const form = init.body as FormData
|
|
expect(form.get('theme_mode')).toBe('custom')
|
|
expect(form.get('theme_preset')).toBe('rose')
|
|
expect(form.get('theme_primary_color')).toBe('#123456')
|
|
expect(form.get('theme_primary_foreground')).toBe('#ffffff')
|
|
expect(form.get('theme_canvas_color')).toBe('#f8fafc')
|
|
expect(form.get('theme_sidebar_accent_color')).toBe('#ffe4e6')
|
|
expect(form.get('theme_ring_color')).toBe('#0f172a')
|
|
})
|
|
|
|
it('includes only logo file fields in FormData when provided', async () => {
|
|
const payload = {
|
|
logo_url: 'https://cdn/logo.png',
|
|
favicon_url: null,
|
|
wordmark_text: null,
|
|
hide_powered_by: false,
|
|
theme: { mode: 'preset', preset: 'default', custom: null, configured: false },
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
const file = new File(['png-data'], 'logo.png', { type: 'image/png' })
|
|
|
|
await saveBranding({ logo: file })
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
const form = init.body as FormData
|
|
expect(form.get('logo')).toBe(file)
|
|
expect(form.get('theme_mode')).toBeNull()
|
|
expect(form.get('theme_preset')).toBeNull()
|
|
expect(form.get('theme_primary_color')).toBeNull()
|
|
expect(form.get('theme_primary_foreground')).toBeNull()
|
|
expect(form.get('theme_canvas_color')).toBeNull()
|
|
expect(form.get('theme_sidebar_accent_color')).toBeNull()
|
|
expect(form.get('theme_ring_color')).toBeNull()
|
|
})
|
|
|
|
it('throws ApiError on 402 (feature gated)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({ error: 'feature_not_available', feature: 'white_label' }, false, 402),
|
|
)
|
|
|
|
await expect(saveBranding({ wordmark_text: 'x' })).rejects.toThrow()
|
|
})
|
|
|
|
it('throws ApiError on 422 validation failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({ error: 'theme_primary_color must be a CSS hex color' }, false, 422),
|
|
)
|
|
|
|
await expect(
|
|
saveBranding({
|
|
theme_mode: 'custom',
|
|
theme_preset: 'default',
|
|
theme_custom: {
|
|
primary_color: 'blue',
|
|
primary_foreground: '#ffffff',
|
|
canvas_color: '#f8fafc',
|
|
sidebar_accent_color: '#dbeafe',
|
|
ring_color: '#0f172a',
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ status: 422 })
|
|
})
|
|
})
|
|
|
|
describe('resetBrandingField', () => {
|
|
it('sends DELETE /api/admin/branding/:field', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ field: 'logo', reset: true }))
|
|
|
|
await resetBrandingField('logo')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/branding/logo')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(resetBrandingField('logo')).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('email config API', () => {
|
|
it('getEmailConfig fetches admin email config', async () => {
|
|
const payload = { enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await getEmailConfig()
|
|
|
|
expect(result).toEqual(payload)
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/email-config')
|
|
expect(init.method).toBe('GET')
|
|
})
|
|
|
|
it('saveEmailConfig PUTs the expected payload', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
|
|
|
|
const payload = { enabled: true, provider: 'cloudflare' as const, from: 'no-reply@zpan.space' }
|
|
await saveEmailConfig(payload)
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/email-config')
|
|
expect(init.method).toBe('PUT')
|
|
expect(init.body).toBe(JSON.stringify(payload))
|
|
})
|
|
|
|
it('saveEmailConfig throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'bad config' }, false, 400))
|
|
|
|
await expect(
|
|
saveEmailConfig({ enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }),
|
|
).rejects.toThrow('bad config')
|
|
})
|
|
|
|
it('testEmail POSTs recipient to test-messages endpoint', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
|
|
|
|
await testEmail('user@example.com')
|
|
|
|
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(url).toContain('/api/admin/email-config/test-messages')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(JSON.stringify({ to: 'user@example.com' }))
|
|
})
|
|
|
|
it('testEmail throws ApiError on failure', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'send failed' }, false, 400))
|
|
|
|
await expect(testEmail('user@example.com')).rejects.toThrow('send failed')
|
|
})
|
|
})
|
|
|
|
describe('listAdminAuditLogs', () => {
|
|
const auditEvent = {
|
|
id: 'evt-1',
|
|
orgId: 'org-1',
|
|
userId: 'user-1',
|
|
action: 'upload',
|
|
targetType: 'file',
|
|
targetId: 'file-1',
|
|
targetName: 'doc.pdf',
|
|
metadata: null,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
user: { id: 'user-1', name: 'Alice', image: null },
|
|
orgName: 'Personal',
|
|
}
|
|
|
|
it('calls correct URL with defaults', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 20 }))
|
|
|
|
await listAdminAuditLogs()
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('/api/admin/audit')
|
|
expect(url).toContain('page=1')
|
|
expect(url).toContain('pageSize=20')
|
|
})
|
|
|
|
it('includes optional filters in query', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 20 }))
|
|
|
|
await listAdminAuditLogs(2, 10, { orgId: 'org-1', userId: 'user-1', action: 'upload', targetType: 'file' })
|
|
|
|
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
|
expect(url).toContain('page=2')
|
|
expect(url).toContain('pageSize=10')
|
|
expect(url).toContain('orgId=org-1')
|
|
expect(url).toContain('userId=user-1')
|
|
expect(url).toContain('action=upload')
|
|
expect(url).toContain('targetType=file')
|
|
})
|
|
|
|
it('returns parsed paginated response', async () => {
|
|
const payload = { items: [auditEvent], total: 1, page: 1, pageSize: 20 }
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
|
|
|
const result = await listAdminAuditLogs()
|
|
|
|
expect(result).toEqual(payload)
|
|
})
|
|
|
|
it('throws ApiError on failed response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
|
|
|
await expect(listAdminAuditLogs()).rejects.toThrow('Forbidden')
|
|
})
|
|
|
|
it('throws ApiError with status 402 when feature unavailable', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeResponse({ error: 'feature_not_available', feature: 'audit_log' }, false, 402),
|
|
)
|
|
|
|
await expect(listAdminAuditLogs()).rejects.toMatchObject({ status: 402 })
|
|
})
|
|
})
|
|
})
|