import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createBlobClient, BlobApiError, BlobUploadError } from './index.js'; const mockFetch = vi.fn(); globalThis.fetch = mockFetch; function jsonResponse(data: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, status, json: () => Promise.resolve(data), headers: new Headers(), } as unknown as Response; } function blobClient(overrides?: Partial[0]>) { return createBlobClient({ baseUrl: 'http://localhost:4003/api', productId: 'testapp', getAccessToken: () => 'test-token', ...overrides, }); } beforeEach(() => { mockFetch.mockReset(); }); describe('createBlobClient', () => { describe('getSasUrl', () => { it('requests a SAS URL from platform-service', async () => { const sasResponse = { sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', container: 'attachments', blobName: 'testapp/user1/photo.jpg', permissions: 'r', expiresInMinutes: 60, expiresAt: '2026-03-02T00:00:00.000Z', }; mockFetch.mockResolvedValueOnce(jsonResponse(sasResponse)); const client = blobClient(); const result = await client.getSasUrl('attachments', 'testapp/user1/photo.jpg'); expect(result).toEqual(sasResponse); expect(mockFetch).toHaveBeenCalledOnce(); const [url, init] = mockFetch.mock.calls[0]; expect(url).toBe('http://localhost:4003/api/blob/sas'); expect(init.method).toBe('POST'); expect(JSON.parse(init.body)).toEqual({ container: 'attachments', blobName: 'testapp/user1/photo.jpg', permissions: 'r', expiresInMinutes: 60, }); }); it('sends auth and product headers', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); const client = blobClient(); await client.getSasUrl('attachments', 'blob.jpg'); const headers = mockFetch.mock.calls[0][1].headers; expect(headers['Authorization']).toBe('Bearer test-token'); expect(headers['x-product-id']).toBe('testapp'); expect(headers['x-request-id']).toBeDefined(); }); it('omits auth header when no token', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); const client = blobClient({ getAccessToken: () => null }); await client.getSasUrl('attachments', 'blob.jpg'); const headers = mockFetch.mock.calls[0][1].headers; expect(headers['Authorization']).toBeUndefined(); }); it('throws BlobApiError on non-ok response', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401)); const client = blobClient(); await expect(client.getSasUrl('releases', 'secret.bin')).rejects.toThrow(BlobApiError); }); }); describe('upload', () => { it('requests SAS then uploads directly to Azure', async () => { const sasResponse = { sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg?sig=abc', container: 'attachments', blobName: 'testapp/user1/photo.jpg', permissions: 'w', expiresInMinutes: 30, expiresAt: '2026-03-02T00:00:00.000Z', }; mockFetch .mockResolvedValueOnce(jsonResponse(sasResponse)) // SAS request .mockResolvedValueOnce({ ok: true, status: 201 } as Response); // Azure upload const client = blobClient(); const result = await client.upload('attachments', 'file-data', { contentType: 'image/jpeg', blobName: 'testapp/user1/photo.jpg', }); expect(result.container).toBe('attachments'); expect(result.blobName).toBe('testapp/user1/photo.jpg'); expect(result.sasUrl).toBe( 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg' ); // Verify Azure upload call const [uploadUrl, uploadInit] = mockFetch.mock.calls[1]; expect(uploadUrl).toBe(sasResponse.sasUrl); expect(uploadInit.method).toBe('PUT'); expect(uploadInit.headers['x-ms-blob-type']).toBe('BlockBlob'); expect(uploadInit.headers['Content-Type']).toBe('image/jpeg'); expect(uploadInit.body).toBe('file-data'); }); it('auto-generates blobName when not provided', async () => { mockFetch .mockResolvedValueOnce( jsonResponse({ sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/123-abc?sig=x', container: 'attachments', blobName: 'testapp/123-abc', permissions: 'w', expiresInMinutes: 30, expiresAt: '2026-03-02T00:00:00.000Z', }) ) .mockResolvedValueOnce({ ok: true, status: 201 } as Response); const client = blobClient(); const result = await client.upload('attachments', 'data', { contentType: 'text/plain', }); // Verify the SAS request included a generated blobName starting with productId const sasBody = JSON.parse(mockFetch.mock.calls[0][1].body); expect(sasBody.blobName).toMatch(/^testapp\//); expect(result.container).toBe('attachments'); }); it('throws BlobUploadError when Azure returns non-ok', async () => { mockFetch .mockResolvedValueOnce( jsonResponse({ sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', container: 'attachments', blobName: 'blob', permissions: 'w', expiresInMinutes: 30, expiresAt: '2026-03-02T00:00:00.000Z', }) ) .mockResolvedValueOnce({ ok: false, status: 403 } as Response); const client = blobClient(); await expect( client.upload('attachments', 'data', { contentType: 'text/plain', blobName: 'blob' }) ).rejects.toThrow(BlobUploadError); }); }); describe('download', () => { it('requests read SAS then fetches the blob', async () => { mockFetch .mockResolvedValueOnce( jsonResponse({ sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', container: 'attachments', blobName: 'blob', permissions: 'r', expiresInMinutes: 15, expiresAt: '2026-03-02T00:00:00.000Z', }) ) .mockResolvedValueOnce({ ok: true, status: 200, blob: () => Promise.resolve(new Blob()), } as unknown as Response); const client = blobClient(); const res = await client.download('attachments', 'blob'); expect(res.ok).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(2); // Second call should be to the SAS URL expect(mockFetch.mock.calls[1][0]).toBe( 'https://storage.blob.core.windows.net/attachments/blob?sig=read' ); }); it('throws BlobApiError on download failure', async () => { mockFetch .mockResolvedValueOnce( jsonResponse({ sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', container: 'attachments', blobName: 'blob', permissions: 'r', expiresInMinutes: 15, expiresAt: '2026-03-02T00:00:00.000Z', }) ) .mockResolvedValueOnce({ ok: false, status: 404 } as Response); const client = blobClient(); await expect(client.download('attachments', 'blob')).rejects.toThrow(BlobApiError); }); }); describe('list', () => { it('lists blobs with prefix and limit', async () => { const listResponse = { blobs: [{ name: 'testapp/user1/photo.jpg', container: 'attachments', size: 1024 }], count: 1, container: 'attachments', prefix: 'testapp/user1/', }; mockFetch.mockResolvedValueOnce(jsonResponse(listResponse)); const client = blobClient(); const result = await client.list('attachments', { prefix: 'testapp/user1/', limit: 10 }); expect(result).toEqual(listResponse); const url = mockFetch.mock.calls[0][0] as string; expect(url).toContain('container=attachments'); expect(url).toContain('prefix=testapp%2Fuser1%2F'); expect(url).toContain('limit=10'); }); it('works without options', async () => { mockFetch.mockResolvedValueOnce( jsonResponse({ blobs: [], count: 0, container: 'audio', prefix: null }) ); const client = blobClient(); await client.list('audio'); const url = mockFetch.mock.calls[0][0] as string; expect(url).toContain('container=audio'); expect(url).not.toContain('prefix='); expect(url).not.toContain('limit='); }); }); describe('info', () => { it('fetches blob metadata', async () => { const infoResponse = { name: 'testapp/user1/photo.jpg', container: 'attachments', contentType: 'image/jpeg', size: 2048, lastModified: '2026-03-01T00:00:00.000Z', url: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg', metadata: {}, }; mockFetch.mockResolvedValueOnce(jsonResponse(infoResponse)); const client = blobClient(); const result = await client.info('attachments', 'testapp/user1/photo.jpg'); expect(result).toEqual(infoResponse); const url = mockFetch.mock.calls[0][0] as string; expect(url).toContain('/blob/info/attachments/testapp%2Fuser1%2Fphoto.jpg'); }); }); });