Add consent-gated media exchange library

This commit is contained in:
jjbrunton committed 2026-08-09 19:24:38 +01:00
1 parent b8e4a1bb88
commit bf474dfe10
42 files changed
+2533 -9

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MediaLibraryAssetSchema } from '../../packages/api-contracts/src/media.js';
import type { WebContext } from '../../src/web/context.js';
import { createMediaLibraryRouter } from '../../src/web/routes/media-library.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
describe('media library routes', () => {
it('lists private assets without exposing their filesystem path', async () => {
const asset = libraryAsset();
const context = { services: { mediaLibrary: { list: () => [asset] } } } as unknown as WebContext;
const response = await request(express().use('/api', createMediaLibraryRouter(context)), '/api/media-library');
const body = await response.json();
expect(MediaLibraryAssetSchema.parse(body[0])).toMatchObject({ id: asset.id, urlPath: `/library-media/${asset.relativePath}` });
expect(body[0]).not.toHaveProperty('relativePath');
});
it('requires consent and passes a raw image to classification', async () => {
const upload = vi.fn().mockResolvedValue(libraryAsset());
const context = { logger: { error: vi.fn() }, services: { mediaLibrary: { upload } } } as unknown as WebContext;
const app = express().use('/api', createMediaLibraryRouter(context));
const response = await request(app, '/api/media-library', {
method: 'POST',
headers: {
'Content-Type': 'image/jpeg',
'X-File-Name': encodeURIComponent('portrait.jpg'),
'X-Consent-Confirmed': 'true',
},
body: Uint8Array.from([1, 2, 3]),
});
expect(response.status).toBe(201);
expect(upload).toHaveBeenCalledWith(expect.objectContaining({
contentType: 'image/jpeg', originalFileName: 'portrait.jpg', consentConfirmed: true,
}));
expect(Buffer.isBuffer(upload.mock.calls[0][0].bytes)).toBe(true);
});
it('validates tag corrections before updating', async () => {
const update = vi.fn();
const context = { services: { mediaLibrary: { update } } } as unknown as WebContext;
const app = express().use(express.json()).use('/api', createMediaLibraryRouter(context));
const response = await request(app, `/api/media-library/${libraryAsset().id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: [], safety: 'sfw', enabled: true }),
});
expect(response.status).toBe(400);
expect(update).not.toHaveBeenCalled();
});
});
async function request(app: express.Express, pathname: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${pathname}`, init);
}
function libraryAsset() {
return {
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'] as const, safety: 'sfw' as const,
classificationStatus: 'ready' as const, description: 'Portrait', confidence: 0.9,
fileName: 'portrait.jpg', relativePath: '019fe789-1b99-7ce1-8402-b3cbc32a445a.jpg', mimeType: 'image/jpeg',
sizeBytes: 100, width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null,
};
}