manifest, thin proxy router, and the sidecar backend
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.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user