0d3cb498a3
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
724 lines
24 KiB
TypeScript
724 lines
24 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|