ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only adapter today), the generation queue, collections, and the database layer — kept on its own Postgres database rather than folded into officer_db, since both collections and provider_settings had rows on the machine this was extracted from. what changed rather than just moved: - images are served from an authenticated GET /files/:filename instead of an unauthenticated PUBLIC_URL-prefixed static route - the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter - provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has no callers left anywhere in the server code, only a migration script that also did not move ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers liveness. not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not been written, so this is not yet a discoverable plugin.
84 lines
3.4 KiB
TypeScript
84 lines
3.4 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import type { Generation } from '../db';
|
|
import { imagesResponse, isSettled, jobStatus } from './response';
|
|
|
|
const row = (over: Partial<Generation> = {}): Generation =>
|
|
({
|
|
id: '11111111-1111-1111-1111-111111111111',
|
|
requestId: '22222222-2222-2222-2222-222222222222',
|
|
index: 0,
|
|
status: 'succeeded',
|
|
provider: 'openai',
|
|
model: 'gpt-image-1',
|
|
prompt: 'a voxel duck',
|
|
options: {},
|
|
imagePath: '11111111-1111-1111-1111-111111111111.png',
|
|
costUsd: '0.010000',
|
|
usage: {
|
|
total_tokens: 100,
|
|
input_tokens: 20,
|
|
output_tokens: 80,
|
|
input_tokens_details: { text_tokens: 20, image_tokens: 0 },
|
|
},
|
|
result: {},
|
|
durationMs: 4200,
|
|
error: null,
|
|
createdAt: new Date('2026-08-16T12:00:00Z'),
|
|
startedAt: new Date('2026-08-16T12:00:01Z'),
|
|
finishedAt: new Date('2026-08-16T12:00:05Z'),
|
|
...over,
|
|
}) as Generation;
|
|
|
|
describe('jobStatus', () => {
|
|
test('running beats queued', () =>
|
|
expect(jobStatus([row({ status: 'queued' }), row({ status: 'running' })])).toBe('running'));
|
|
test('queued while any is waiting', () => expect(jobStatus([row(), row({ status: 'queued' })])).toBe('queued'));
|
|
test('succeeded when all did', () => expect(jobStatus([row(), row()])).toBe('succeeded'));
|
|
test('failed when all did', () =>
|
|
expect(jobStatus([row({ status: 'failed' }), row({ status: 'failed' })])).toBe('failed'));
|
|
|
|
// The case n>1 exists for: three images made, one refused. Calling that "failed" discards work that
|
|
// was paid for; calling it "succeeded" hides a failure.
|
|
test('partial when some failed and some did not', () =>
|
|
expect(jobStatus([row(), row({ status: 'failed' })])).toBe('partial'));
|
|
});
|
|
|
|
describe('isSettled', () => {
|
|
test.each(['succeeded', 'failed', 'partial'] as const)('%s is settled', (s) => expect(isSettled(s)).toBe(true));
|
|
test.each(['queued', 'running'] as const)('%s is not', (s) => expect(isSettled(s)).toBe(false));
|
|
});
|
|
|
|
describe('imagesResponse', () => {
|
|
test('shapes an OpenAI response, with url populated', async () => {
|
|
const body = await imagesResponse([row()], false);
|
|
|
|
expect(body.created).toBe(Math.floor(new Date('2026-08-16T12:00:00Z').getTime() / 1000));
|
|
expect(body.data).toHaveLength(1);
|
|
// The divergence that is the point of Atelier: OpenAI never returns url for GPT image models.
|
|
expect(body.data[0]!.url).toMatch(/\/files\/11111111-1111-1111-1111-111111111111\.png$/);
|
|
expect(body.data[0]).not.toHaveProperty('b64_json');
|
|
});
|
|
|
|
test('omits failed images from data but keeps them in the atelier block', async () => {
|
|
const body = await imagesResponse([row(), row({ status: 'failed', imagePath: null, error: 'refused' })], false);
|
|
|
|
expect(body.data).toHaveLength(1);
|
|
expect(body.atelier.images).toHaveLength(2);
|
|
expect(body.atelier.status).toBe('partial');
|
|
expect(body.atelier.images[1]!.error).toBe('refused');
|
|
});
|
|
|
|
test('sums usage and cost across images', async () => {
|
|
const body = await imagesResponse([row(), row({ costUsd: '0.020000' })], false);
|
|
|
|
expect(body.usage.total_tokens).toBe(200);
|
|
expect(body.usage.output_tokens).toBe(160);
|
|
expect(body.atelier.cost_usd).toBe('0.030000');
|
|
});
|
|
|
|
test('reports revised_prompt when the provider rewrote it', async () => {
|
|
const body = await imagesResponse([row({ result: { revised_prompt: 'a cheerful voxel duck' } })], false);
|
|
expect(body.data[0]!.revised_prompt).toBe('a cheerful voxel duck');
|
|
});
|
|
});
|