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.
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import * as serializers from './serialize';
|
|
import type { Collection, CollectionWithCount, Generation } from './db';
|
|
|
|
const generation = (over: Partial<Generation> = {}): Generation =>
|
|
({
|
|
id: '11111111-1111-1111-1111-111111111111',
|
|
requestId: '22222222-2222-2222-2222-222222222222',
|
|
index: 0,
|
|
operation: 'generate',
|
|
status: 'succeeded',
|
|
provider: 'openai',
|
|
model: 'gpt-image-1',
|
|
prompt: 'a duck',
|
|
options: { size: '1024x1024' },
|
|
inputImages: null,
|
|
maskImage: null,
|
|
imagePath: '11111111-1111-1111-1111-111111111111.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
hasAlpha: true,
|
|
opaque: false,
|
|
costUsd: '0.167075',
|
|
usage: null,
|
|
result: null,
|
|
durationMs: 40903,
|
|
error: null,
|
|
createdAt: new Date(),
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
...over,
|
|
}) as Generation;
|
|
|
|
const collection = (over: Partial<CollectionWithCount> = {}): CollectionWithCount =>
|
|
({
|
|
id: '33333333-3333-3333-3333-333333333333',
|
|
name: 'OfficerAppIcons',
|
|
description: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
itemCount: 6,
|
|
coverImagePath: '11111111-1111-1111-1111-111111111111.png',
|
|
...over,
|
|
}) as CollectionWithCount;
|
|
|
|
/**
|
|
* One sample per serialiser.
|
|
*
|
|
* The test below fails if this map does not cover every function exported from ./serialize. That is
|
|
* the actual fix for the bug this file exists because of: the previous version tested the two
|
|
* generation serialisers by name, so when a collections payload started going out raw it sailed past
|
|
* a suite that was green. Now a new serialiser cannot be added without a case.
|
|
*/
|
|
const CASES: Record<string, () => unknown> = {
|
|
serializeGeneration: () => serializers.serializeGeneration(generation()),
|
|
serializeGenerationImage: () => serializers.serializeGenerationImage(generation()),
|
|
serializeCollection: () => serializers.serializeCollection(collection() as Collection),
|
|
serializeCollectionSummary: () => serializers.serializeCollectionSummary(collection()),
|
|
};
|
|
|
|
const exported = Object.entries(serializers)
|
|
.filter(([, value]) => typeof value === 'function')
|
|
.map(([name]) => name);
|
|
|
|
const CAMEL = /[a-z][A-Z]/;
|
|
|
|
describe('every serialiser', () => {
|
|
test('has a case in this file', () => {
|
|
expect(exported.filter((name) => !(name in CASES))).toEqual([]);
|
|
});
|
|
|
|
test.each(exported)('%s emits no camelCase keys', (name) => {
|
|
const output = CASES[name]!() as Record<string, unknown>;
|
|
expect(Object.keys(output).filter((key) => CAMEL.test(key))).toEqual([]);
|
|
});
|
|
|
|
test.each(exported)('%s emits no key ending in Id, At or Path', (name) => {
|
|
// The specific spellings that leaked: createdAt, updatedAt, itemCount, coverImagePath.
|
|
const output = CASES[name]!() as Record<string, unknown>;
|
|
expect(Object.keys(output).filter((key) => /(Id|At|Path|Count|Url)$/.test(key))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('shapes that must agree', () => {
|
|
test("the compact image form uses the full form's spellings", () => {
|
|
const full = Object.keys(serializers.serializeGeneration(generation()));
|
|
const compact = Object.keys(serializers.serializeGenerationImage(generation()));
|
|
expect(compact.filter((key) => !full.includes(key))).toEqual([]);
|
|
});
|
|
|
|
test('a collection summary is a collection plus counts', () => {
|
|
const base = Object.keys(serializers.serializeCollection(collection() as Collection));
|
|
const summary = Object.keys(serializers.serializeCollectionSummary(collection()));
|
|
expect(base.filter((key) => !summary.includes(key))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('values', () => {
|
|
test('cost stays a string, so totals do not round', () => {
|
|
expect(serializers.serializeGeneration(generation()).cost_usd).toBe('0.167075');
|
|
});
|
|
|
|
test('a row with no file has a null url rather than a broken one', () => {
|
|
expect(serializers.serializeGeneration(generation({ imagePath: null })).url).toBeNull();
|
|
});
|
|
|
|
test('an empty collection has a null cover, not a url to nothing', () => {
|
|
expect(serializers.serializeCollectionSummary(collection({ coverImagePath: null })).cover_url).toBeNull();
|
|
});
|
|
});
|