Files
zpan/src/lib/api.test.ts
T

4177 lines
149 KiB
TypeScript

// Tests for src/lib/api.ts — covers all public API helper functions
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ApiError,
abortObjectUpload,
buildShareObjectUrl,
cancelBackgroundJob,
cancelCloudOrder,
clearSessionCache,
completeObjectUpload,
confirmIhostImage,
connectCloud,
continueCloudOrderPayment,
copyObject,
createAnnouncement,
createBackgroundJob,
createCloudBillingPortalSession,
createCloudCheckout,
createDiscountQuote,
createDownloadTask,
createIhostApiKey,
createIhostImagePresign,
createObject,
createRemoteDownloadApiKey,
createShare,
createSiteInvitation,
createStorage,
createWebDavAppPassword,
deleteAnnouncement,
deleteAuthProvider,
deleteAvatar,
deleteDownloader,
deleteIhostConfig,
deleteIhostImage,
deleteInviteCode,
deleteObject,
deleteStorage,
deleteTeamLogo,
disconnectCloud,
enableIhostFeature,
generateInviteCodes,
getAdminDashboardGrowthStats,
getAdminDashboardOperationsStats,
getAdminDashboardOverviewStats,
getAdminDashboardSharingStats,
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
getAdminOverview,
getAnnouncement,
getBackgroundJob,
getChangelog,
getCloudCredits,
getEmailConfig,
getIhostConfig,
getInstanceInfo,
getLicensingStatus,
getObject,
getProfile,
getSession,
getShare,
getSiteConfig,
getSiteInvitation,
getSiteSettings,
getStorage,
getTeam,
getTrashObject,
getUnreadCount,
getUserQuota,
getUserQuotaById,
grantOrgEntitlement,
grantUserEntitlement,
isNameConflictError,
listActiveAnnouncements,
listAdminAnnouncements,
listAdminAuditLogs,
listAnnouncements,
listAuthProviders,
listBackgroundJobs,
listCloudCreditLedgerEntries,
listCloudCreditProducts,
listCloudOrders,
listCloudProducts,
listCloudStoreTargets,
listDownloaders,
listDownloadTaskEvents,
listDownloadTasks,
listIhostApiKeys,
listIhostImages,
listInviteCodes,
listNotifications,
listObjectsByPath,
listOrgEntitlements,
listQuotas,
listReceivedShares,
listRemoteDownloadApiKeys,
listShareObjects,
listShares,
listSiteInvitations,
listStorages,
listTeamActivities,
listTeams,
listTrash,
listUserEntitlements,
listWebDavAppPasswords,
markAllNotificationsRead,
markNotificationRead,
pollPairing,
presignObjectUploadParts,
purgeTrashObject,
redeemCloudGiftCard,
refreshLicense,
resendSiteInvitation,
resetBrandingField,
restoreObject,
retryBackgroundJob,
revokeIhostApiKey,
revokeOrgEntitlement,
revokeRemoteDownloadApiKey,
revokeShare,
revokeSiteInvitation,
revokeUserEntitlement,
revokeWebDavAppPassword,
runDownloadTaskAction,
saveBranding,
saveEmailConfig,
saveShareToDrive,
sendDownloaderHeartbeat,
serverEventsUrl,
testEmail,
transferObject,
updateAnnouncement,
updateDownloader,
updateDownloaderCreditBilling,
updateDownloadTask,
updateIhostConfig,
updateObject,
updateOrgEntitlement,
updateSiteCaptcha,
updateSiteIdentity,
updateSiteQuotas,
updateSiteRegistration,
updateStorage,
updateStorageEgressBilling,
updateUserEntitlement,
uploadAvatar,
uploadPartToS3,
uploadTeamLogo,
uploadToS3,
upsertAuthProvider,
verifySharePassword,
verifySiteWebDav,
} 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())
clearSessionCache()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('listObjectsByPath (defaults and unwrap edge cases)', () => {
it('calls correct URL with defaults', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
await listObjectsByPath('root')
const [url] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects?')
expect(url).toContain('path=root')
expect(url).toContain('page=1')
expect(url).toContain('pageSize=500')
})
it('uses provided page, pageSize, and opts', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 2, pageSize: 20 }))
await listObjectsByPath('folder1', 2, 20, { type: 'image', search: 'cat', orgId: 'org-1' })
const [url] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('path=folder1')
expect(url).toContain('page=2')
expect(url).toContain('pageSize=20')
expect(url).toContain('type=image')
expect(url).toContain('search=cat')
expect(url).toContain('orgId=org-1')
})
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 listObjectsByPath('root')
expect(result).toEqual(payload)
})
it('throws when response is not ok', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(listObjectsByPath('root')).rejects.toThrow('forbidden')
})
it('falls back to HTTP status when error body has no error field', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({}, false, 500))
await expect(listObjectsByPath('root')).rejects.toThrow('HTTP 500')
})
it('falls back to HTTP status when json parse fails', async () => {
const res = {
ok: false,
status: 503,
statusText: 'Service Unavailable',
json: async () => {
throw new Error('parse error')
},
} as unknown as Response
vi.mocked(fetch).mockResolvedValueOnce(res)
await expect(listObjectsByPath('root')).rejects.toThrow('HTTP 503')
})
it('passes credentials: include', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
await listObjectsByPath('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/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/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/site/invitations/invite-1/deliveries')
expect(init.method).toBe('POST')
})
it('revokes a site invitation (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(revokeSiteInvitation('invite-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/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('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/status')
expect(calls[4][1].method).toBe('PUT')
expect(JSON.parse(calls[4][1].body as string)).toEqual({ status: 'canceled' })
})
it('sends the promotion code in the checkout body when provided', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse({ orderId: 'order-1', url: 'https://cloud.example/checkout' }),
)
const result = await createCloudCheckout('pkg-1', 'price-usd', 'SAVE10')
expect(result).toEqual({ orderId: 'order-1', url: 'https://cloud.example/checkout' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/store/checkouts')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({
packageId: 'pkg-1',
priceId: 'price-usd',
promotionCode: 'SAVE10',
})
})
it('creates a discount quote for a price and code', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse({ code: 'SAVE10', currency: 'usd', subtotal: 9900, discount: 990, total: 8910 }),
)
const quote = await createDiscountQuote('SAVE10', 'price-usd')
expect(quote).toEqual({ code: 'SAVE10', currency: 'usd', subtotal: 9900, discount: 990, total: 8910 })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/store/discount-quotes')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({ code: 'SAVE10', priceId: 'price-usd' })
})
it.each([
['listCloudProducts', () => listCloudProducts()],
['listCloudStoreTargets', () => listCloudStoreTargets()],
['getCloudCredits', () => getCloudCredits()],
['listCloudCreditLedgerEntries', () => listCloudCreditLedgerEntries()],
['redeemCloudGiftCard', () => redeemCloudGiftCard('GIFT-123')],
['createCloudCheckout', () => createCloudCheckout('pkg-1')],
['createDiscountQuote', () => createDiscountQuote('SAVE10', 'price-usd')],
['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 and returns the draft with upload instructions', async () => {
const created = {
id: 'new1',
name: 'doc.pdf',
upload: { sessionId: 'sess-1', partSize: 5 * 1024 * 1024, urls: ['https://s3/part-1'] },
}
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)
expect(result.upload).toEqual({ sessionId: 'sess-1', partSize: 5 * 1024 * 1024, urls: ['https://s3/part-1'] })
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('includes storageId when creating a targeted object draft', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'new1', name: 'doc.pdf' }))
await createObject({
name: 'doc.pdf',
type: 'application/pdf',
size: 1024,
parent: 'root',
dirtype: 0,
storageId: 'st-1',
})
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(JSON.parse(init.body as string)).toMatchObject({ storageId: 'st-1' })
})
it('returns a folder without upload instructions', async () => {
const created = { id: 'folder1', name: 'photos', type: 'folder' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(created))
const result = await createObject({ name: 'photos', type: 'folder', parent: 'root', dirtype: 1 })
expect(result).toEqual(created)
expect(result.upload).toBeUndefined()
})
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')
})
it('throws ApiError with structured targeted-storage failures', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse(
{
error: {
code: 503,
message: 'Storage is not active or has no available capacity',
status: 'UNAVAILABLE',
details: [{ reason: 'NO_STORAGE_CONFIGURED', domain: 'zpan.dev' }],
},
},
false,
503,
),
)
await expect(
createObject({ name: 'f', type: 't', parent: 'p', dirtype: 0, storageId: 'st-full' }),
).rejects.toMatchObject({
name: 'ApiError',
status: 503,
message: 'Storage is not active or has no available capacity',
reason: 'NO_STORAGE_CONFIGURED',
})
})
})
describe('completeObjectUpload', () => {
it('posts parts to /api/objects/:id/uploads/:sessionId/completions and returns the live object', async () => {
const live = { id: 'obj-1', name: 'doc.pdf', status: 'active' }
const parts = [
{ partNumber: 1, etag: 'etag-1' },
{ partNumber: 2, etag: 'etag-2' },
]
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(live))
const result = await completeObjectUpload('obj-1', 'sess-1', parts)
expect(result).toEqual(live)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/obj-1/uploads/sess-1/completions')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({ parts })
})
it('throws ApiError on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'invalid parts' }, false, 400))
await expect(completeObjectUpload('obj-1', 'sess-1', [])).rejects.toThrow('invalid parts')
})
})
describe('abortObjectUpload', () => {
it('sends DELETE to /api/objects/:id/uploads/:sessionId and resolves on 204', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(abortObjectUpload('obj-1', 'sess-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/obj-1/uploads/sess-1?')
expect(init.method).toBe('DELETE')
})
it('passes strict cleanup query when requested', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await abortObjectUpload('obj-1', 'sess-1', { strictStorageCleanup: true })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/obj-1/uploads/sess-1?strictStorageCleanup=1')
expect(init.method).toBe('DELETE')
})
it('throws ApiError on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(abortObjectUpload('obj-1', 'missing')).rejects.toThrow('not found')
})
})
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({ 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({ 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('deleteObject', () => {
it('sends DELETE request (soft delete to trash) and resolves on 204', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(deleteObject('id1')).resolves.toBeUndefined()
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 /:id/copies with 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).toBe('/api/objects/id1/copies')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ 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('transferObject', () => {
it('posts to /:id/transfers with target org, parent, and mode', async () => {
const payload = { saved: [{ id: 'new1' }], skipped: [], sourceTrashed: true }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await transferObject('id1', { targetOrgId: 'org-team', targetParent: 'photos', mode: 'move' })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects/id1/transfers')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ targetOrgId: 'org-team', targetParent: 'photos', mode: 'move' })
})
it('throws on quota exceeded response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse(
{
error: {
code: 422,
message: 'Quota exceeded',
status: 'RESOURCE_EXHAUSTED',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'QUOTA_EXCEEDED',
domain: 'zpan.dev',
},
],
},
},
false,
422,
),
)
await expect(
transferObject('id1', { targetOrgId: 'org-team', targetParent: '', mode: 'copy' }),
).rejects.toMatchObject({ name: 'ApiError', status: 422, reason: 'QUOTA_EXCEEDED' })
})
})
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('uploadPartToS3', () => {
class MockPartXHR {
static instances: MockPartXHR[] = []
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
responseHeaders: Record<string, string> = { ETag: '"etag-abc"' }
constructor() {
MockPartXHR.instances.push(this)
}
open(method: string, url: string) {
this.method = method
this.url = url
}
getResponseHeader(key: string) {
return this.responseHeaders[key] ?? null
}
send(body: unknown) {
this.body = body
}
abort() {
this.onabort?.()
}
}
beforeEach(() => {
MockPartXHR.instances = []
vi.stubGlobal('XMLHttpRequest', MockPartXHR)
})
it('PUTs the blob and resolves with the unquoted ETag', async () => {
const blob = new Blob(['chunk'])
const promise = uploadPartToS3('https://s3/part-1', blob)
const xhr = MockPartXHR.instances[0]
xhr.onload?.()
await expect(promise).resolves.toBe('etag-abc')
expect(xhr.method).toBe('PUT')
expect(xhr.url).toBe('https://s3/part-1')
expect(xhr.body).toBe(blob)
})
it('rejects when the ETag header is not exposed', async () => {
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']))
const xhr = MockPartXHR.instances[0]
xhr.responseHeaders = {}
xhr.onload?.()
await expect(promise).rejects.toThrow(/ETag/)
})
it('rejects when the part upload fails', async () => {
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']))
const xhr = MockPartXHR.instances[0]
xhr.status = 500
xhr.onload?.()
await expect(promise).rejects.toThrow('Upload failed')
})
it('rejects immediately when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']), { signal: controller.signal })
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
expect(MockPartXHR.instances[0]?.body).toBeUndefined()
})
it('rejects on a network error', async () => {
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']))
const xhr = MockPartXHR.instances[0]
xhr.onerror?.()
await expect(promise).rejects.toThrow('Upload failed')
})
it('reports progress and rejects on abort', async () => {
const onProgress = vi.fn()
const controller = new AbortController()
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']), {
onProgress,
signal: controller.signal,
})
const xhr = MockPartXHR.instances[0]
xhr.upload.onprogress?.({ loaded: 2, total: 8, lengthComputable: true } as ProgressEvent)
expect(onProgress).toHaveBeenCalledWith({ loaded: 2, total: 8 })
controller.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
})
})
describe('presignObjectUploadParts', () => {
it('presigns parts via POST /api/objects/:id/uploads/:sessionId/parts', async () => {
const payload = {
uploadId: 'mp-1',
partSize: 5 * 1024 * 1024,
parts: [{ partNumber: 1, url: 'https://s3/part-1' }],
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await presignObjectUploadParts('obj-1', 'sess-1', { partNumbers: [1] })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/obj-1/uploads/sess-1/parts')
expect(init.method).toBe('POST')
expect(init.body).toBe(JSON.stringify({ partNumbers: [1] }))
})
it('throws ApiError on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'upload denied' }, false, 403))
await expect(presignObjectUploadParts('obj-1', 'sess-1', { partNumbers: [1] })).rejects.toThrow('upload denied')
})
})
describe('listTrash', () => {
it('calls GET /api/trash/objects with default pagination', async () => {
const payload = { items: [], total: 0, page: 1, pageSize: 20 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await listTrash()
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/trash/objects?')
expect(url).toContain('page=1')
expect(url).toContain('pageSize=20')
expect(init.method).toBe('GET')
})
it('passes provided page and pageSize', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 3, pageSize: 50 }))
await listTrash(3, 50)
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('page=3')
expect(url).toContain('pageSize=50')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(listTrash()).rejects.toThrow('forbidden')
})
})
describe('getTrashObject', () => {
it('fetches a trashed object via GET /api/trash/objects/:id', async () => {
// A trashed object is active with trashedAt set.
const obj = { id: 'id1', name: 'file.txt', status: 'active', trashedAt: 1700000000000 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
const result = await getTrashObject('id1')
expect(result).toEqual(obj)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/trash/objects/id1')
expect(init.method).toBe('GET')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(getTrashObject('missing')).rejects.toThrow('not found')
})
})
describe('restoreObject', () => {
it('posts to /api/trash/objects/:id/restorations and returns the restored object', 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).toBe('/api/trash/objects/id1/restorations')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toEqual({})
})
it('forwards an onConflict strategy in the body', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'id1', status: 'active' }))
await restoreObject('id1', 'rename')
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({ onConflict: 'rename' })
})
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('purgeTrashObject', () => {
it('sends DELETE to /api/trash/objects/:id and resolves on 204', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(purgeTrashObject('id1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/trash/objects/id1')
expect(init.method).toBe('DELETE')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'server error' }, false, 500))
await expect(purgeTrashObject('id1')).rejects.toThrow('server error')
})
})
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: 'downloading',
assignedTo: 'me',
category: 'movies',
tag: '4k',
sortBy: 'progress',
sortDir: 'asc',
page: 2,
pageSize: 10,
})
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/downloads/tasks?')
expect(url).toContain('status=downloading')
expect(url).toContain('assignedTo=me')
expect(url).toContain('category=movies')
expect(url).toContain('tag=4k')
expect(url).toContain('sortBy=progress')
expect(url).toContain('sortDir=asc')
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',
category: 'archives',
tags: ['backup', '2026'],
}
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/downloads/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: 'downloading' }
const body = { status: 'downloading' as const, progress: { download: { bytes: 1024, bytesPerSecond: 0 } } }
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/downloads/tasks/task-1')
expect(init.method).toBe('PATCH')
expect(init.body).toBe(JSON.stringify(body))
})
it('lists download task events', async () => {
const payload = { items: [{ id: 'event-1', action: 'download_task_created' }] }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await listDownloadTaskEvents('task-1')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1/events')
expect(init.method).toBe('GET')
})
it('throws when listing download task events fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(listDownloadTaskEvents('missing')).rejects.toThrow('not found')
})
it('pauses a download task via PUT /status', async () => {
const payload = { id: 'task-1', status: 'paused' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await runDownloadTaskAction('task-1', 'pause')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1/status')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify({ status: 'paused' }))
})
it('resumes a download task via PUT /status with queued', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'task-1', status: 'queued' }))
await runDownloadTaskAction('task-1', 'resume')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1/status')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify({ status: 'queued' }))
})
it('cancels a download task via PUT /status with canceled', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'task-1', status: 'canceled' }))
await runDownloadTaskAction('task-1', 'cancel')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1/status')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify({ status: 'canceled' }))
})
it('retries a download task via POST /attempts', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'task-1', status: 'queued' }, true, 201))
await runDownloadTaskAction('task-1', 'retry')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1/attempts')
expect(init.method).toBe('POST')
expect(init.body).toBe(JSON.stringify({ fresh: false }))
})
it('restarts a download task via POST /attempts with fresh', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'task-1', status: 'queued' }, true, 201))
await runDownloadTaskAction('task-1', 'restart')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1/attempts')
expect(init.method).toBe('POST')
expect(init.body).toBe(JSON.stringify({ fresh: true }))
})
it('deletes a download task via DELETE (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(runDownloadTaskAction('task-1', 'delete')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1')
expect(init.method).toBe('DELETE')
})
it('throws ApiError on download task action failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Only active tasks can be paused' }, false, 409))
await expect(runDownloadTaskAction('task-1', 'pause')).rejects.toThrow('Only active tasks can be paused')
})
it('builds the unified server events URL from RPC client', () => {
expect(serverEventsUrl().pathname).toBe('/api/events')
const url = serverEventsUrl({ downloadTasks: '1', dtStatus: 'downloading', dtSortDir: 'desc' })
expect(url.pathname).toBe('/api/events')
expect(url.searchParams.get('downloadTasks')).toBe('1')
expect(url.searchParams.get('dtStatus')).toBe('downloading')
expect(url.searchParams.get('dtSortDir')).toBe('desc')
})
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/downloads/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/downloads/downloaders/downloader-1')
expect(init.method).toBe('PATCH')
expect(init.body).toBe(JSON.stringify(body))
})
it('updates downloader credit billing via dedicated route', async () => {
const payload = { id: 'downloader-1', remoteDownloadCreditBillingEnabled: true }
const body = { enabled: true, unitBytes: 2048, creditsPerUnit: 3 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await updateDownloaderCreditBilling('downloader-1', body)
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/downloads/downloaders/downloader-1/credit-billing')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify(body))
})
it('throws ApiError on downloader credit billing failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Feature not available' }, false, 402))
await expect(
updateDownloaderCreditBilling('downloader-1', { enabled: true, unitBytes: 1, creditsPerUnit: 1 }),
).rejects.toBeInstanceOf(ApiError)
})
it('deletes an admin downloader (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(deleteDownloader('downloader-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/downloads/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/downloads/downloaders/me/heartbeats')
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', { progress: { download: { bytes: 2048, bytesPerSecond: 0 } } }),
).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/status')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify({ status: 'canceled' }))
})
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/retries')
expect(init.method).toBe('POST')
})
it('throws ApiError for background job failures', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse(
{
error: {
code: 409,
message: 'Background job cannot be retried',
status: 'FAILED_PRECONDITION',
details: [],
},
},
false,
409,
),
)
await expect(retryBackgroundJob('job-1')).rejects.toMatchObject({
name: 'ApiError',
status: 409,
message: '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/site/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 = {
provider: 'aws-s3',
bucket: 'files',
endpoint: 'https://minio.example.com',
region: 'us-east-1',
accessKey: 'key',
secretKey: 'secret',
capacity: 1073741824,
forcePathStyle: false,
}
it('posts storage data and returns created storage', async () => {
const storage = { id: 's1', 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/site/storages')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ provider: 'aws-s3', bucket: 'files', forcePathStyle: false })
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/site/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', bucket: 'updated-files' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
const result = await updateStorage('s1', {
provider: 'custom-s3',
bucket: 'updated-files',
forcePathStyle: false,
})
expect(result).toEqual(storage)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/storages/s1')
expect(init.method).toBe('PUT')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ provider: 'custom-s3', bucket: 'updated-files', forcePathStyle: false })
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(updateStorage('s1', { bucket: 'x' })).rejects.toThrow('forbidden')
})
})
describe('updateStorageEgressBilling', () => {
it('puts storage egress billing data and returns updated storage', async () => {
const storage = { id: 's1', egressCreditBillingEnabled: true }
const body = { enabled: true, unitBytes: 2048, creditsPerUnit: 3 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
const result = await updateStorageEgressBilling('s1', body)
expect(result).toEqual(storage)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/storages/s1/egress-billing')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify(body))
})
it('throws ApiError on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Feature not available' }, false, 402))
await expect(
updateStorageEgressBilling('s1', { enabled: true, unitBytes: 1, creditsPerUnit: 1 }),
).rejects.toBeInstanceOf(ApiError)
})
})
describe('deleteStorage', () => {
it('sends DELETE request (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(deleteStorage('s1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/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('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/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/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('updates 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: 4096,
startsAt: '2026-05-01T00:00:00.000Z',
expiresAt: null,
status: 'active',
metadata: null,
createdAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
},
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await updateUserEntitlement('u1', 'ent-2', { bytes: 4096, expiresAt: null })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/users/u1/entitlements/ent-2')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ bytes: 4096, expiresAt: null })
})
it('revokes a user quota entitlement (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(revokeUserEntitlement('u1', 'ent-2')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/users/u1/entitlements/ent-2')
expect(init.method).toBe('DELETE')
})
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/quotas')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(listQuotas()).rejects.toThrow('forbidden')
})
})
describe('getUserQuotaById', () => {
it('fetches a single user quota from the user sub-resource', async () => {
const payload = { used: 512, total: 1024, hasPersonalOrg: true }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getUserQuotaById('u1')
expect(result).toEqual(payload)
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('/api/users/u1/quota')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(getUserQuotaById('u1')).rejects.toThrow('forbidden')
})
})
describe('teams', () => {
it('lists teams', async () => {
const payload = { items: [{ id: 'team-1', name: 'Alpha' }], total: 1 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await listTeams()
expect(result).toEqual(payload)
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('/api/teams')
})
it('gets a team detail', async () => {
const payload = { id: 'team-1', name: 'Alpha', quotaTotal: 20971520 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getTeam('team-1')
expect(result).toEqual(payload)
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('/api/teams/team-1')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(getTeam('missing')).rejects.toThrow('not found')
})
})
describe('org entitlements', () => {
it('lists entitlements for an org', async () => {
const payload = { orgId: 'team-1', items: [] }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await listOrgEntitlements('team-1')
expect(result).toEqual(payload)
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('/api/teams/team-1/entitlements')
})
it('grants an entitlement to an org', async () => {
const payload = { orgId: 'team-1', entitlement: { id: 'ent-1', bytes: 1024 } }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await grantOrgEntitlement('team-1', { resourceType: 'storage', bytes: 1024, note: 'starter' })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/teams/team-1/entitlements')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ resourceType: 'storage', bytes: 1024, note: 'starter' })
})
it('updates an org entitlement', async () => {
const payload = { orgId: 'team-1', entitlement: { id: 'ent-1', bytes: 4096 } }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await updateOrgEntitlement('team-1', 'ent-1', { bytes: 4096 })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/teams/team-1/entitlements/ent-1')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ bytes: 4096 })
})
it('revokes an org entitlement (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(revokeOrgEntitlement('team-1', 'ent-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/teams/team-1/entitlements/ent-1')
expect(init.method).toBe('DELETE')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(listOrgEntitlements('missing')).rejects.toThrow('not found')
})
})
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('site configuration', () => {
const config = {
site: { name: 'ZPan', description: '', publicUrl: 'https://pan.example.com' },
branding: {
logoUrl: null,
faviconUrl: null,
wordmark: null,
hidePoweredBy: false,
theme: { mode: 'preset', preset: 'default', custom: null, configured: false },
},
auth: { signupMode: 'invite_only', captcha: { enabled: false }, providers: [] },
services: { webdav: { url: 'https://pan.example.com/dav/' } },
} as const
const settings = {
identity: config.site,
registration: { configuredMode: 'open', effectiveMode: 'invite_only' },
captcha: {
enabled: false,
provider: 'cloudflare-turnstile',
siteKey: '',
secretConfigured: false,
minScore: null,
},
quotas: { defaultOrgBytes: 1024, defaultTeamBytes: 1024, defaultMonthlyTrafficBytes: 0 },
webdav: {
pathUrl: 'https://pan.example.com/dav/',
candidateUrl: 'https://dav.pan.example.com/',
status: 'unverified',
lastVerifiedAt: null,
error: null,
},
} as const
it('gets the public configz document', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(config))
await expect(getSiteConfig()).resolves.toEqual(config)
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain('/api/configz')
})
it('throws when configz fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'failed' }, false, 500))
await expect(getSiteConfig()).rejects.toThrow('failed')
})
it('gets the structured admin settings', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(settings))
await expect(getSiteSettings()).resolves.toEqual(settings)
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain('/api/site/settings')
})
it('throws when reading settings fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
await expect(getSiteSettings()).rejects.toThrow('unauthorized')
})
it('updates identity with a structured payload', async () => {
const input = { name: 'ZPan', description: 'Files', publicUrl: 'https://pan.example.com' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(input))
await expect(updateSiteIdentity(input)).resolves.toEqual(input)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/settings/identity')
expect(init.method).toBe('PUT')
expect(JSON.parse(String(init.body))).toEqual(input)
})
it('throws when updating identity fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(
updateSiteIdentity({ name: 'ZPan', description: '', publicUrl: 'https://pan.example.com' }),
).rejects.toThrow('forbidden')
})
it('updates registration mode', async () => {
const result = { configuredMode: 'closed', effectiveMode: 'closed' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(result))
await expect(updateSiteRegistration({ mode: 'closed' })).resolves.toEqual(result)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/settings/registration')
expect(init.method).toBe('PUT')
expect(JSON.parse(String(init.body))).toEqual({ mode: 'closed' })
})
it('throws when updating registration fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'blocked' }, false, 402))
await expect(updateSiteRegistration({ mode: 'open' })).rejects.toThrow('blocked')
})
it('updates captcha without requiring a secret in every request', async () => {
const input = {
enabled: true,
provider: 'hcaptcha' as const,
siteKey: 'site-key',
minScore: null,
}
const result = { ...input, secretConfigured: true }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(result))
await expect(updateSiteCaptcha(input)).resolves.toEqual(result)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/settings/captcha')
expect(init.method).toBe('PUT')
expect(JSON.parse(String(init.body))).toEqual(input)
})
it('throws when updating captcha fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'invalid' }, false, 400))
await expect(
updateSiteCaptcha({ enabled: true, provider: 'hcaptcha', siteKey: '', minScore: null }),
).rejects.toThrow('invalid')
})
it('updates all quota settings atomically', async () => {
const input = { defaultOrgBytes: 1024, defaultTeamBytes: 2048, defaultMonthlyTrafficBytes: 0 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(input))
await expect(updateSiteQuotas(input)).resolves.toEqual(input)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/settings/quotas')
expect(init.method).toBe('PUT')
expect(JSON.parse(String(init.body))).toEqual(input)
})
it('throws when updating quotas fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'invalid' }, false, 400))
await expect(
updateSiteQuotas({ defaultOrgBytes: 0, defaultTeamBytes: 0, defaultMonthlyTrafficBytes: 0 }),
).rejects.toThrow('invalid')
})
it('verifies the derived WebDAV domain', async () => {
const result = { ...settings.webdav, status: 'ready', lastVerifiedAt: '2026-07-20T12:00:00.000Z' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(result))
await expect(verifySiteWebDav()).resolves.toEqual(result)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/settings/webdav/verification')
expect(init.method).toBe('POST')
})
it('throws when WebDAV verification fails at the API boundary', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
await expect(verifySiteWebDav()).rejects.toMatchObject({ name: 'ApiError', status: 401 })
})
})
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')
expect(init.signal).toBeInstanceOf(AbortSignal)
})
it('throws ApiError when response is not ok', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
const promise = getSession()
await expect(promise).rejects.toMatchObject({ name: 'ApiError', status: 401 })
})
it('aborts and throws when the session request times out', async () => {
vi.useFakeTimers()
vi.mocked(fetch).mockImplementationOnce(
(_url, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
}),
)
const promise = getSession()
const assertion = expect(promise).rejects.toThrow('Session request timed out')
await vi.advanceTimersByTimeAsync(10_000)
await assertion
vi.useRealTimers()
})
it('shares one in-flight request across concurrent callers', async () => {
const session = { session: { id: 'sess1' }, user: { id: 'u1' } }
let resolveFetch: (res: Response) => void = () => {}
vi.mocked(fetch).mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve
}),
)
const first = getSession()
const second = getSession()
resolveFetch(makeResponse(session))
expect(await first).toEqual(session)
expect(await second).toEqual(session)
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)
})
it('serves a resolved session from cache within the TTL and refetches after it', async () => {
vi.useFakeTimers()
const session = { session: { id: 'sess1' }, user: { id: 'u1' } }
vi.mocked(fetch).mockResolvedValue(makeResponse(session))
await getSession()
await getSession()
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5_001)
await getSession()
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('keeps sharing a slow in-flight request past the TTL instead of piling up duplicates', async () => {
vi.useFakeTimers()
const session = { session: { id: 'sess1' }, user: { id: 'u1' } }
let resolveFetch: (res: Response) => void = () => {}
vi.mocked(fetch).mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve
}),
)
const first = getSession()
await vi.advanceTimersByTimeAsync(6_000)
const second = getSession()
resolveFetch(makeResponse(session))
expect(await first).toEqual(session)
expect(await second).toEqual(session)
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('does not cache failures — the next call retries', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse({ error: 'unavailable' }, false, 503))
.mockResolvedValueOnce(makeResponse({ session: { id: 'sess1' }, user: { id: 'u1' } }))
await expect(getSession()).rejects.toMatchObject({ name: 'ApiError', status: 503 })
const result = await getSession()
expect(result).toEqual({ session: { id: 'sess1' }, user: { id: 'u1' } })
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2)
})
})
describe('listAuthProviders', () => {
it('fetches auth providers list from /api/site/auth-providers', async () => {
const payload = {
items: [{ providerId: 'github', type: 'oauth', name: 'GitHub', icon: '' }],
callbackBaseUri: 'https://files.example',
}
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/site/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], callbackBaseUri: 'https://files.example' }),
)
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: [], callbackBaseUri: 'https://files.example' }))
const result = await listAuthProviders()
expect(result.items).toHaveLength(0)
expect(result.callbackBaseUri).toBe('https://files.example')
})
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: [], callbackBaseUri: 'https://files.example' }))
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/users/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, 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, 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/site/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/site/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/site/announcements')
expect(url).toContain('scope=all')
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/site/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/site/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/site/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 (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(deleteAnnouncement('ann-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/site/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('listReceivedShares', () => {
it('calls /api/shares with box=received', async () => {
const payload = { items: [], total: 0, page: 1, pageSize: 20 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await listReceivedShares()
expect(result).toEqual(payload)
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('/api/shares')
expect(url).toContain('box=received')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
await expect(listReceivedShares()).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('revokeShare', () => {
it('puts status: revoked to /api/shares/:token/status and resolves with the revoked share', async () => {
const payload = { token: 'tok123', status: 'revoked', kind: 'landing' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await revokeShare('tok123')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/shares/tok123/status')
expect(init.method).toBe('PUT')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toEqual({ status: 'revoked' })
})
it('throws ApiError on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(revokeShare('tok123')).rejects.toThrow('Forbidden')
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(revokeShare('tok123')).rejects.toBeInstanceOf(ApiError)
})
})
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 reason on 400', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse(
{
error: {
code: 400,
message: 'Quota exceeded',
status: 'FAILED_PRECONDITION',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'QUOTA_EXCEEDED',
domain: 'zpan.dev',
},
],
},
},
false,
400,
),
)
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toMatchObject({
name: 'ApiError',
status: 400,
reason: '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/image-hosting/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/image-hosting/config')
})
it('returns the disabled shape when feature is not enabled', async () => {
const disabled = {
enabled: false,
customDomain: null,
domainVerifiedAt: null,
domainStatus: 'none',
dnsInstructions: null,
refererAllowlist: null,
createdAt: null,
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(disabled))
const result = await getIhostConfig()
expect(result).toEqual(disabled)
expect(result.enabled).toBe(false)
})
it('passes credentials: include', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ enabled: false }))
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/image-hosting/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/image-hosting/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/image-hosting/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/image-hosting/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/image-hosting/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: { ihost: ['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 ihost: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: { ihost: ['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.configId).toBe('ihost')
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.configId).toBe('ihost')
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('Remote download API keys', () => {
const sampleKey = {
id: 'remote-key-1',
name: 'Remote Download',
start: 'zpan',
prefix: null,
createdAt: '2024-01-01T00:00:00.000Z',
lastRequest: null,
permissions: { remoteDownload: ['read', 'create', 'cancel'] },
referenceId: 'org-1',
enabled: true,
}
it('lists only remote-download API keys', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse({
apiKeys: [sampleKey, { ...sampleKey, id: 'other', permissions: { ihost: ['upload'] } }],
}),
)
const result = await listRemoteDownloadApiKeys('org-1')
expect(result).toEqual([sampleKey])
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/auth/api-key/list?organizationId=org-1&configId=remote-download')
expect(init.method).toBe('GET')
})
it('creates a remote-download API key with configId', async () => {
const created = { ...sampleKey, key: 'remote-secret' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(created))
const result = await createRemoteDownloadApiKey('org-1', 'Remote Download')
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: 'remote-download',
name: 'Remote Download',
organizationId: 'org-1',
})
})
it('revokes a remote-download API key with configId', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
const result = await revokeRemoteDownloadApiKey('remote-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: 'remote-download', keyId: 'remote-key-1' })
})
it('throws ApiError on remote-download API key failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Unauthorized' }, false, 401))
await expect(listRemoteDownloadApiKeys('org-1')).rejects.toThrow('Unauthorized')
})
})
describe('listIhostImages', () => {
it('calls GET /api/image-hosting/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/image-hosting/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/image-hosting/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/image-hosting/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 PUT /api/image-hosting/images/:id/status', 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).toBe('/api/image-hosting/images/img-1/status')
expect(init.method).toBe('PUT')
})
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/image-hosting/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/image-hosting/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/users/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/users/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/users/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/users/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/site/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('getInstanceInfo', () => {
const instance = {
id: 'inst-123',
name: 'My ZPan',
url: 'https://files.example.com',
version: '2.5.0',
runtime: 'node',
platform: 'docker',
server: { os: { platform: 'linux', arch: 'x64', release: '6.1.0' } },
node: { version: 'v24.0.0' },
}
it('calls the correct endpoint', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(instance))
await getInstanceInfo()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/instance')
expect(init?.method ?? 'GET').toBe('GET')
})
it('returns the instance info', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(instance))
const result = await getInstanceInfo()
expect(result).toEqual(instance)
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(getInstanceInfo()).rejects.toThrow()
})
})
describe('getChangelog', () => {
const changelog = {
currentVersion: '2.7.2',
latestVersion: '2.8.0',
updateAvailable: true,
markdown: '## [2.8.0]\n- new stuff',
}
it('calls the correct endpoint', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(changelog))
await getChangelog()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/changelog')
expect(init?.method ?? 'GET').toBe('GET')
})
it('returns the changelog payload', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(changelog))
const result = await getChangelog()
expect(result).toEqual(changelog)
})
it('appends refresh=true to bypass the server cache', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(changelog))
await getChangelog({ refresh: true })
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/changelog')
expect(url).toContain('refresh=true')
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(getChangelog()).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/site/licensing/pairings')
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/site/licensing/pairings/ABC-123')
})
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/site/licensing/refresh-runs')
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(null, true, 204))
await disconnectCloud()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/licensing/binding')
expect(init.method).toBe('DELETE')
})
it('resolves on 204', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(disconnectCloud()).resolves.toBeUndefined()
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(disconnectCloud()).rejects.toThrow()
})
})
describe('saveBranding', () => {
it('sends PUT /api/site/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/site/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: 'data:image/png;base64,cG5nLWRhdGE=',
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/site/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/site/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,
requireEmailVerification: 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/site/email')
expect(init.method).toBe('GET')
})
it('saveEmailConfig PUTs the expected payload', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
const payload = {
enabled: true,
requireEmailVerification: 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/site/email')
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,
requireEmailVerification: false,
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/site/email/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('site stats API', () => {
const dashboardPayload = {
generatedAt: '2026-07-09T00:00:00.000Z',
from: '2026-07-01T00:00:00.000Z',
to: '2026-07-09T00:00:00.000Z',
}
const range = {
from: '2026-07-01T00:00:00.000Z',
to: '2026-07-09T00:00:00.000Z',
timeZone: 'UTC' as const,
}
const dashboardEndpoints = [
{ name: 'getAdminDashboardOverviewStats', path: '/api/site/stats/overview', fn: getAdminDashboardOverviewStats },
{
name: 'getAdminDashboardOperationsStats',
path: '/api/site/stats/operations',
fn: getAdminDashboardOperationsStats,
},
{ name: 'getAdminDashboardGrowthStats', path: '/api/site/stats/growth', fn: getAdminDashboardGrowthStats },
{ name: 'getAdminDashboardStorageStats', path: '/api/site/stats/storage', fn: getAdminDashboardStorageStats },
{ name: 'getAdminDashboardTrafficStats', path: '/api/site/stats/traffic', fn: getAdminDashboardTrafficStats },
{ name: 'getAdminDashboardSharingStats', path: '/api/site/stats/sharing', fn: getAdminDashboardSharingStats },
] as const
for (const endpoint of dashboardEndpoints) {
it(`${endpoint.name} fetches dashboard stats with from/to`, async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(dashboardPayload))
const result = await endpoint.fn(range)
expect(result).toEqual(dashboardPayload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain(endpoint.path)
expect(decodeURIComponent(url)).toContain(`from=${range.from}`)
expect(decodeURIComponent(url)).toContain(`to=${range.to}`)
expect(decodeURIComponent(url)).toContain(`timeZone=${range.timeZone}`)
expect(init.method).toBe('GET')
})
it(`${endpoint.name} throws ApiError on failure`, async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Feature not available' }, false, 402))
await expect(endpoint.fn(range)).rejects.toMatchObject({ status: 402 })
})
}
})
describe('admin runtime overview API', () => {
const overview = {
observedAt: '2026-07-20T18:00:00.000Z',
status: 'healthy',
alerts: [],
storages: { total: 1, writable: 1, items: [] },
downloaders: {
total: 1,
online: 1,
activeTasks: 0,
totalSlots: 2,
availableSlots: 2,
downloadBps: 0,
uploadBps: 0,
items: [],
},
queue: { waiting: 0, oldestCreatedAt: null },
license: {
bound: false,
active: false,
lastRefreshAt: null,
lastRefreshError: null,
certificateExpiresAt: null,
},
}
it('GETs the type-safe site overview resource', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(overview))
await expect(getAdminOverview()).resolves.toEqual(overview)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/site/overview')
expect(init.method).toBe('GET')
})
it('throws ApiError when the overview request fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'overview failed' }, false, 500))
await expect(getAdminOverview()).rejects.toMatchObject({ status: 500 })
})
})
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/site/audit-events')
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',
createdFrom: '2026-01-01T00:00:00.000Z',
createdTo: '2026-02-01T00:00:00.000Z',
})
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')
expect(url).toContain('createdFrom=2026-01-01T00%3A00%3A00.000Z')
expect(url).toContain('createdTo=2026-02-01T00%3A00%3A00.000Z')
})
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 })
})
})
describe('listObjectsByPath', () => {
it('sends path and optional filters as query params', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
await listObjectsByPath('a/b', 2, 50, { type: 'dir', search: 'doc', orgId: 'org-1' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects?')
expect(url).toContain('path=a%2Fb')
expect(url).toContain('page=2')
expect(url).toContain('pageSize=50')
expect(url).toContain('type=dir')
expect(url).toContain('search=doc')
expect(url).toContain('orgId=org-1')
expect(init.method).toBe('GET')
})
it('omits absent optional filters', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
await listObjectsByPath('root')
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).not.toContain('type=')
expect(url).not.toContain('search=')
expect(url).not.toContain('orgId=')
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(listObjectsByPath('root')).rejects.toThrow('forbidden')
})
})
describe('isNameConflictError', () => {
const errorBody = (reason: string, metadata?: Record<string, string>) => ({
error: {
code: 409,
message: 'Name already exists',
status: 'ALREADY_EXISTS',
details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason, domain: 'zpan.dev', metadata }],
},
})
it('returns true only for 409 NAME_CONFLICT ApiErrors', () => {
const conflict = new ApiError(409, errorBody('NAME_CONFLICT', { conflictingName: 'a', conflictingId: 'id1' }))
expect(isNameConflictError(conflict)).toBe(true)
expect(conflict.metadata).toEqual({ conflictingName: 'a', conflictingId: 'id1' })
expect(conflict.reason).toBe('NAME_CONFLICT')
})
it('returns false for other ApiErrors and non-errors', () => {
expect(isNameConflictError(new ApiError(409, errorBody('OTHER')))).toBe(false)
expect(isNameConflictError(new ApiError(404, errorBody('NAME_CONFLICT')))).toBe(false)
expect(isNameConflictError(new Error('nope'))).toBe(false)
expect(isNameConflictError(null)).toBe(false)
})
})
describe('admin auth providers api', () => {
it('upserts an auth provider', async () => {
const data = { enabled: true, clientId: 'cid', clientSecret: 'secret' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ providerId: 'google', ...data }))
await upsertAuthProvider('google', data as never)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/site/auth-providers/google')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify(data))
})
it('deletes an auth provider (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(deleteAuthProvider('google')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/site/auth-providers/google')
expect(init.method).toBe('DELETE')
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(upsertAuthProvider('google', {} as never)).rejects.toThrow('forbidden')
})
})
describe('invite codes api', () => {
it('lists invite codes with pagination', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
await listInviteCodes(3, 25)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/invite-codes?')
expect(url).toContain('page=3')
expect(url).toContain('pageSize=25')
expect(init.method).toBe('GET')
})
it('generates invite codes with count only', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ codes: [] }))
await generateInviteCodes(5)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/site/invite-codes')
expect(init.method).toBe('POST')
expect(init.body).toBe(JSON.stringify({ count: 5 }))
})
it('includes expiresInDays when provided', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ codes: [] }))
await generateInviteCodes(2, 7)
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(init.body).toBe(JSON.stringify({ count: 2, expiresInDays: 7 }))
})
it('deletes an invite code (resolves on 204)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(deleteInviteCode('code-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/site/invite-codes/code-1')
expect(init.method).toBe('DELETE')
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(generateInviteCodes(1)).rejects.toThrow('forbidden')
})
})
describe('listTeamActivities', () => {
it('fetches team activity with pagination', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 20 }))
await listTeamActivities('team-1', 2, 15)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/teams/team-1/activity?')
expect(url).toContain('page=2')
expect(url).toContain('pageSize=15')
expect(init.method).toBe('GET')
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(listTeamActivities('team-1')).rejects.toThrow('forbidden')
})
})
})