Files
simstudioai--sim/apps/sim/lib/uploads/providers/blob/client.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

246 lines
7.5 KiB
TypeScript

/**
* Tests for Azure Blob Storage client
*
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockUpload,
mockDownload,
mockDelete,
mockDeleteIfExists,
mockGetBlockBlobClient,
mockGetContainerClient,
mockFromConnectionString,
mockStorageSharedKeyCredential,
mockGenerateBlobSASQueryParameters,
mockBlobSASPermissionsParse,
} = vi.hoisted(() => ({
mockUpload: vi.fn(),
mockDownload: vi.fn(),
mockDelete: vi.fn(),
mockDeleteIfExists: vi.fn(),
mockGetBlockBlobClient: vi.fn(),
mockGetContainerClient: vi.fn(),
mockFromConnectionString: vi.fn(),
mockStorageSharedKeyCredential: vi.fn(),
mockGenerateBlobSASQueryParameters: vi.fn(),
mockBlobSASPermissionsParse: vi.fn(),
}))
vi.mock('@azure/storage-blob', () => ({
BlobServiceClient: {
fromConnectionString: mockFromConnectionString,
},
StorageSharedKeyCredential: mockStorageSharedKeyCredential,
generateBlobSASQueryParameters: mockGenerateBlobSASQueryParameters,
BlobSASPermissions: {
parse: mockBlobSASPermissionsParse,
},
}))
vi.mock('@/lib/uploads/config', () => ({
BLOB_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
connectionString:
'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net',
containerName: 'testcontainer',
},
}))
import {
deleteFromBlob,
downloadFromBlob,
getPresignedUrl,
parseConnectionString,
uploadToBlob,
} from '@/lib/uploads/providers/blob/client'
import { sanitizeFilenameForMetadata } from '@/lib/uploads/utils/file-utils'
describe('Azure Blob Storage Client', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBlobSASPermissionsParse.mockReturnValue('r')
mockGetBlockBlobClient.mockReturnValue({
upload: mockUpload,
download: mockDownload,
delete: mockDelete,
deleteIfExists: mockDeleteIfExists,
url: 'https://test.blob.core.windows.net/container/test-file',
})
mockGetContainerClient.mockReturnValue({
getBlockBlobClient: mockGetBlockBlobClient,
})
mockFromConnectionString.mockReturnValue({
getContainerClient: mockGetContainerClient,
})
mockGenerateBlobSASQueryParameters.mockReturnValue({
toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test',
})
})
describe('uploadToBlob', () => {
it('should upload a file to Azure Blob Storage', async () => {
const testBuffer = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
mockUpload.mockResolvedValueOnce({})
const result = await uploadToBlob(testBuffer, fileName, contentType)
expect(mockUpload).toHaveBeenCalledWith(testBuffer, testBuffer.length, {
blobHTTPHeaders: {
blobContentType: contentType,
},
metadata: {
originalName: encodeURIComponent(fileName),
uploadedAt: expect.any(String),
},
})
expect(result).toEqual({
path: expect.stringContaining('/api/files/serve/'),
key: expect.stringContaining(fileName.replace(/\s+/g, '-')),
name: fileName,
size: testBuffer.length,
type: contentType,
})
})
it('should handle custom blob configuration', async () => {
const testBuffer = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
const customConfig = {
containerName: 'customcontainer',
accountName: 'customaccount',
accountKey: 'customkey',
}
mockUpload.mockResolvedValueOnce({})
const result = await uploadToBlob(testBuffer, fileName, contentType, customConfig)
expect(mockGetContainerClient).toHaveBeenCalledWith('customcontainer')
expect(result.name).toBe(fileName)
expect(result.type).toBe(contentType)
})
})
describe('downloadFromBlob', () => {
it('should download a file from Azure Blob Storage', async () => {
const testKey = 'test-file-key'
const testContent = Buffer.from('downloaded content')
const mockReadableStream = {
on: vi.fn((event, callback) => {
if (event === 'data') {
callback(testContent)
} else if (event === 'end') {
callback()
}
}),
off: vi.fn(() => mockReadableStream),
}
mockDownload.mockResolvedValueOnce({
readableStreamBody: mockReadableStream,
})
const result = await downloadFromBlob(testKey)
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
expect(mockDownload).toHaveBeenCalled()
expect(result).toEqual(testContent)
})
it('should destroy the opened stream when content length exceeds the limit', async () => {
const mockDestroy = vi.fn()
const mockReadableStream = {
destroy: mockDestroy,
on: vi.fn(() => mockReadableStream),
}
mockDownload.mockResolvedValueOnce({
readableStreamBody: mockReadableStream,
contentLength: 1024,
})
await expect(downloadFromBlob('large-file-key', undefined, 10)).rejects.toThrow(
'storage download exceeds maximum size'
)
expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error))
})
})
describe('deleteFromBlob', () => {
it('should delete a file from Azure Blob Storage', async () => {
const testKey = 'test-file-key'
mockDeleteIfExists.mockResolvedValueOnce({})
await deleteFromBlob(testKey)
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
expect(mockDeleteIfExists).toHaveBeenCalled()
})
})
describe('getPresignedUrl', () => {
it('should generate a presigned URL for Azure Blob Storage', async () => {
const testKey = 'test-file-key'
const expiresIn = 3600
const result = await getPresignedUrl(testKey, expiresIn)
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
expect(mockGenerateBlobSASQueryParameters).toHaveBeenCalled()
expect(result).toContain('https://test.blob.core.windows.net/container/test-file')
expect(result).toContain('sv=2021-06-08')
})
})
describe('parseConnectionString', () => {
it('extracts accountName and accountKey from a well-formed connection string', () => {
const result = parseConnectionString(
'DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=mykey123;EndpointSuffix=core.windows.net'
)
expect(result).toEqual({ accountName: 'myaccount', accountKey: 'mykey123' })
})
it('throws when AccountName is missing', () => {
expect(() =>
parseConnectionString('DefaultEndpointsProtocol=https;AccountKey=mykey123')
).toThrow('Cannot extract account name from connection string')
})
it('throws when AccountKey is missing', () => {
expect(() =>
parseConnectionString('DefaultEndpointsProtocol=https;AccountName=myaccount')
).toThrow('Cannot extract account key from connection string')
})
})
describe('sanitizeFilenameForMetadata', () => {
const testCases = [
{ input: 'test file.txt', expected: 'test file.txt' },
{ input: 'test"file.txt', expected: 'testfile.txt' },
{ input: 'test\\file.txt', expected: 'testfile.txt' },
{ input: 'test file.txt', expected: 'test file.txt' },
{ input: '', expected: 'file' },
]
it.each(testCases)('should sanitize "$input" to "$expected"', ({ input, expected }) => {
expect(sanitizeFilenameForMetadata(input)).toBe(expected)
})
})
})