277 lines
9.4 KiB
TypeScript
277 lines
9.4 KiB
TypeScript
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<Parameters<typeof createBlobClient>[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');
|
|
});
|
|
});
|
|
});
|