// 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) 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) 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 = {} 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 = { 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) 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((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((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) 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) => ({ 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') }) }) })