chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/blobs/extractor');
|
||||
vi.mock('../../../src/blobs');
|
||||
vi.mock('../../../src/database');
|
||||
|
||||
// Import after mocking
|
||||
import { getBlobByHash, getBlobUrl } from '../../../src/blobs';
|
||||
import { isBlobStorageEnabled } from '../../../src/blobs/extractor';
|
||||
import { getDb } from '../../../src/database';
|
||||
|
||||
const mockedIsBlobStorageEnabled = vi.mocked(isBlobStorageEnabled);
|
||||
const mockedGetBlobUrl = vi.mocked(getBlobUrl);
|
||||
const mockedGetBlobByHash = vi.mocked(getBlobByHash);
|
||||
const mockedGetDb = vi.mocked(getDb);
|
||||
|
||||
describe('Blobs Routes', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/blobs/:hash', () => {
|
||||
const validHash = 'a'.repeat(64);
|
||||
|
||||
// Create chainable mock DB that returns assetResult on first .get() and referenceResult on second
|
||||
function createMockDb(assetResult?: any, referenceResult?: any) {
|
||||
let callCount = 0;
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
return callCount === 1 ? assetResult : referenceResult;
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function setupDbWithAssetAndReference(
|
||||
asset: Record<string, unknown>,
|
||||
reference: Record<string, unknown> = { evalId: 'eval-123' },
|
||||
) {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createMockDb(asset, reference);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
}
|
||||
|
||||
function createBlobResponse(mimeType: string, sizeBytes: number) {
|
||||
return {
|
||||
data: Buffer.alloc(sizeBytes),
|
||||
metadata: {
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
createdAt: new Date().toISOString(),
|
||||
provider: 'local',
|
||||
key: validHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return 404 when blob storage is disabled', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(false);
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Blob storage disabled' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['too short', 'abc123'],
|
||||
['invalid characters', 'g'.repeat(64)],
|
||||
['too long', 'a'.repeat(65)],
|
||||
])('should return 400 for invalid hash (%s)', async (_label, hash) => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get(`/api/blobs/${hash}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 404 when blob asset not found in DB', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockReturnValue(createMockDb(undefined));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Blob not found' });
|
||||
});
|
||||
|
||||
it('should return 403 when no reference exists', async () => {
|
||||
const mockAsset = {
|
||||
hash: validHash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
provider: 'local',
|
||||
};
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockReturnValue(createMockDb(mockAsset, undefined));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Not authorized to access this blob' });
|
||||
});
|
||||
|
||||
it('should redirect 302 when presigned URL is available', async () => {
|
||||
setupDbWithAssetAndReference({
|
||||
hash: validHash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
provider: 's3',
|
||||
});
|
||||
|
||||
const presignedUrl = 'https://s3.amazonaws.com/bucket/blob?signature=xyz';
|
||||
mockedGetBlobUrl.mockResolvedValue(presignedUrl);
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.header.location).toBe(presignedUrl);
|
||||
expect(mockedGetBlobUrl).toHaveBeenCalledWith(validHash);
|
||||
expect(mockedGetBlobByHash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should serve blob data directly when no presigned URL', async () => {
|
||||
setupDbWithAssetAndReference({
|
||||
hash: validHash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
provider: 'local',
|
||||
});
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('image/png', 1024));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.header['content-type']).toBe('image/png');
|
||||
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.header['accept-ranges']).toBe('none');
|
||||
// Content-Length may be absent if response is gzipped (Express uses transfer-encoding: chunked)
|
||||
expect(
|
||||
response.header['content-length'] === '1024' ||
|
||||
response.header['transfer-encoding'] === 'chunked',
|
||||
).toBe(true);
|
||||
expect(mockedGetBlobByHash).toHaveBeenCalledWith(validHash);
|
||||
});
|
||||
|
||||
it('should use fallback MIME type for invalid MIME types', async () => {
|
||||
setupDbWithAssetAndReference(
|
||||
{ hash: validHash, mimeType: 'audio/wav.html', sizeBytes: 2048, provider: 'local' },
|
||||
{ evalId: 'eval-456' },
|
||||
);
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('audio/wav.html', 2048));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.header['content-type']).toBe('application/octet-stream');
|
||||
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.header['accept-ranges']).toBe('none');
|
||||
});
|
||||
|
||||
it('should use blob metadata MIME type when available', async () => {
|
||||
setupDbWithAssetAndReference(
|
||||
{ hash: validHash, mimeType: 'image/png', sizeBytes: 1024, provider: 'local' },
|
||||
{ evalId: 'eval-789' },
|
||||
);
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
// Blob metadata has different MIME type and size than the asset record
|
||||
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('image/jpeg', 2048));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.header['content-type']).toBe('image/jpeg');
|
||||
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.header['accept-ranges']).toBe('none');
|
||||
// Content-Length may be absent if response is gzipped
|
||||
expect(
|
||||
response.header['content-length'] === '2048' ||
|
||||
response.header['transfer-encoding'] === 'chunked',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 404 when getBlobByHash throws error', async () => {
|
||||
setupDbWithAssetAndReference(
|
||||
{ hash: validHash, mimeType: 'text/plain', sizeBytes: 512, provider: 'local' },
|
||||
{ evalId: 'eval-error' },
|
||||
);
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
mockedGetBlobByHash.mockRejectedValue(new Error('File system error'));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Blob not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/blobs/library', () => {
|
||||
/**
|
||||
* Create a mock DB for library queries.
|
||||
* The /library route executes 3 chained queries:
|
||||
* 1. COUNT query via .get() → { count: number }
|
||||
* 2. uniqueHashes query via .all() → [{ hash: string }]
|
||||
* 3. items detail query via .all() → full item rows
|
||||
*
|
||||
* We track calls to .get() and .all() to return the right data.
|
||||
*/
|
||||
function createLibraryMockDb(
|
||||
countResult: { count: number },
|
||||
uniqueHashes: Array<{ hash: string }>,
|
||||
items: any[],
|
||||
) {
|
||||
let getAllCallCount = 0;
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
selectDistinct: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockReturnThis(),
|
||||
get: vi.fn().mockReturnValue(countResult),
|
||||
all: vi.fn().mockImplementation(() => {
|
||||
getAllCallCount++;
|
||||
// First .all() call: uniqueHashes query
|
||||
// Second .all() call: items detail query
|
||||
return getAllCallCount === 1 ? uniqueHashes : items;
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty list with blobStorageEnabled=false when blob storage is disabled', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(false);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: { items: [], total: 0, hasMore: false, blobStorageEnabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get('/api/blobs/library?limit=abc');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid query parameters',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid type filter', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get('/api/blobs/library?type=invalid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid query parameters',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty list when no items match', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createLibraryMockDb({ count: 0 }, [], []);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.items).toEqual([]);
|
||||
expect(response.body.data.total).toBe(0);
|
||||
expect(response.body.data.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should return items with correct response shape (list mode)', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'a'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
createdAt: '2025-01-01 00:00:00',
|
||||
evalId: 'eval-1',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: 'response',
|
||||
kind: 'image',
|
||||
evalDescription: 'Test eval',
|
||||
provider: { id: 'openai:gpt-4', label: 'GPT-4' },
|
||||
success: true,
|
||||
score: 0.9,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.total).toBe(1);
|
||||
expect(response.body.data.hasMore).toBe(false);
|
||||
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.hash).toBe(hash1);
|
||||
expect(item.mimeType).toBe('image/png');
|
||||
expect(item.sizeBytes).toBe(1024);
|
||||
expect(item.kind).toBe('image');
|
||||
expect(item.url).toBe(`/api/blobs/${hash1}`);
|
||||
expect(item.context.evalId).toBe('eval-1');
|
||||
expect(item.context.evalDescription).toBe('Test eval');
|
||||
expect(item.context.provider).toBe('GPT-4');
|
||||
expect(item.context.pass).toBe(true);
|
||||
expect(item.context.score).toBe(0.9);
|
||||
// Detail-only fields should NOT be in list responses
|
||||
expect(item.context.prompt).toBeUndefined();
|
||||
expect(item.context.variables).toBeUndefined();
|
||||
expect(item.context.graderResults).toBeUndefined();
|
||||
expect(item.context.latencyMs).toBeUndefined();
|
||||
expect(item.context.cost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return detail fields when hash filter is provided', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'a'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
createdAt: '2025-01-01 00:00:00',
|
||||
evalId: 'eval-1',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: 'response',
|
||||
kind: 'image',
|
||||
evalDescription: 'Test eval',
|
||||
provider: { id: 'openai:gpt-4', label: 'GPT-4' },
|
||||
prompt: { raw: 'Generate an image' },
|
||||
success: true,
|
||||
score: 0.9,
|
||||
gradingResult: null,
|
||||
testCase: { vars: { prompt: 'test' } },
|
||||
latencyMs: 500,
|
||||
cost: 0.01,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get(`/api/blobs/library?hash=${hash1}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.context.prompt).toBe('Generate an image');
|
||||
expect(item.context.variables).toEqual({ prompt: 'test' });
|
||||
expect(item.context.latencyMs).toBe(500);
|
||||
expect(item.context.cost).toBe(0.01);
|
||||
});
|
||||
|
||||
it('should handle string provider format (legacy)', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'b'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'audio/wav',
|
||||
sizeBytes: 2048,
|
||||
createdAt: 1700000000,
|
||||
evalId: 'eval-2',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: null,
|
||||
kind: null,
|
||||
evalDescription: null,
|
||||
provider: 'openai:tts-1',
|
||||
prompt: null,
|
||||
success: null,
|
||||
score: null,
|
||||
gradingResult: null,
|
||||
testCase: null,
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.context.provider).toBe('openai:tts-1');
|
||||
// Kind should be derived from mimeType when not set in reference
|
||||
expect(item.kind).toBe('audio');
|
||||
});
|
||||
|
||||
it('should indicate hasMore when more items exist beyond the page', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'c'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/jpeg',
|
||||
sizeBytes: 512,
|
||||
createdAt: '2025-06-01 12:00:00',
|
||||
evalId: 'eval-3',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: null,
|
||||
kind: 'image',
|
||||
evalDescription: null,
|
||||
provider: null,
|
||||
prompt: null,
|
||||
success: null,
|
||||
score: null,
|
||||
gradingResult: null,
|
||||
testCase: null,
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Total is 5 but only 1 returned (limit=1, offset=0)
|
||||
const mockDb = createLibraryMockDb({ count: 5 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library?limit=1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.total).toBe(5);
|
||||
expect(response.body.data.hasMore).toBe(true);
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should accept valid filter parameters', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createLibraryMockDb({ count: 0 }, [], []);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
// Type filter
|
||||
const res1 = await api.get('/api/blobs/library?type=image');
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// Sort parameters
|
||||
const res2 = await api.get('/api/blobs/library?sortField=sizeBytes&sortOrder=asc');
|
||||
expect(res2.status).toBe(200);
|
||||
|
||||
// Eval filter
|
||||
const res3 = await api.get('/api/blobs/library?evalId=some-eval-id');
|
||||
expect(res3.status).toBe(200);
|
||||
|
||||
// Hash filter (deep link)
|
||||
const hash = 'd'.repeat(64);
|
||||
const res4 = await api.get(`/api/blobs/library?hash=${hash}`);
|
||||
expect(res4.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle grading results in detail response', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'e'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
createdAt: '2025-01-01 00:00:00',
|
||||
evalId: 'eval-grading',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: null,
|
||||
kind: 'image',
|
||||
evalDescription: null,
|
||||
provider: null,
|
||||
prompt: null,
|
||||
success: true,
|
||||
score: 0.75,
|
||||
gradingResult: {
|
||||
componentResults: [
|
||||
{ pass: true, score: 1.0, reason: 'Looks good', assertion: { type: 'human' } },
|
||||
{ pass: false, score: 0.5, reason: 'Low quality' },
|
||||
],
|
||||
},
|
||||
testCase: null,
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
// Use hash filter to trigger detail mode
|
||||
const response = await api.get(`/api/blobs/library?hash=${hash1}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.context.graderResults).toHaveLength(2);
|
||||
expect(item.context.graderResults[0]).toEqual({
|
||||
name: 'human',
|
||||
pass: true,
|
||||
score: 1.0,
|
||||
reason: 'Looks good',
|
||||
});
|
||||
expect(item.context.graderResults[1]).toEqual({
|
||||
name: 'Grader 2',
|
||||
pass: false,
|
||||
score: 0.5,
|
||||
reason: 'Low quality',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/blobs/library/evals', () => {
|
||||
function createEvalsMockDb(evals: any[]) {
|
||||
return {
|
||||
selectDistinct: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockReturnValue(evals),
|
||||
} as any;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty array when blob storage is disabled', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(false);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ success: true, data: [] });
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals?limit=abc');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid query parameters',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return evals with correct shape', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const mockEvals = [
|
||||
{
|
||||
evalId: 'eval-abc-123',
|
||||
description: 'Image generation test',
|
||||
createdAt: '2025-06-15 10:30:00',
|
||||
},
|
||||
{
|
||||
evalId: 'eval-def-456',
|
||||
description: null,
|
||||
createdAt: 1700000000,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createEvalsMockDb(mockEvals);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
|
||||
// First eval with description
|
||||
expect(response.body.data[0].evalId).toBe('eval-abc-123');
|
||||
expect(response.body.data[0].description).toBe('Image generation test');
|
||||
expect(response.body.data[0].createdAt).toBeDefined();
|
||||
|
||||
// Second eval falls back to truncated ID
|
||||
expect(response.body.data[1].evalId).toBe('eval-def-456');
|
||||
expect(response.body.data[1].description).toBe('Eval eval-def');
|
||||
});
|
||||
|
||||
it('should respect custom limit parameter', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createEvalsMockDb([]);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals?limit=5');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should pass search query to where clause', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createEvalsMockDb([]);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals?search=test');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add where clause when search is empty', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createEvalsMockDb([]);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// where is called with undefined (no conditions)
|
||||
expect(mockDb.where).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should reject search strings exceeding max length', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const longSearch = 'a'.repeat(201);
|
||||
const response = await api.get(`/api/blobs/library/evals?search=${longSearch}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user