Add consent-gated media exchange library
This commit is contained in:
1 parent
b8e4a1bb88
commit
bf474dfe10
42 files changed
+2533
-9
No files matched your search
@@ -13,6 +13,7 @@ function harness(
|
||||
rapportTurnsBeforeMediaAsk?: number;
|
||||
},
|
||||
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
|
||||
mediaExchange?: { detect(conversationId: string, text: string): string | null; handle(conversationId: string, incomingKey: string, text: string): Promise<object> },
|
||||
) {
|
||||
const saved = new Map<string, ConversationState>();
|
||||
let lastSaved: ConversationState | undefined;
|
||||
@@ -63,6 +64,7 @@ function harness(
|
||||
() => 1_000,
|
||||
0,
|
||||
beforeSendLoader ?? (async (_conversationId, observed) => observed),
|
||||
mediaExchange as never,
|
||||
);
|
||||
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
|
||||
}
|
||||
@@ -79,6 +81,22 @@ function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ original
|
||||
}
|
||||
|
||||
describe('conversation automation', () => {
|
||||
it('handles a requested library image through the reply limiter before generating text', async () => {
|
||||
const mediaExchange = {
|
||||
detect: vi.fn().mockReturnValue('generic'),
|
||||
handle: vi.fn().mockResolvedValue({ kind: 'sent', assetId: 'asset-one', requestKind: 'generic' }),
|
||||
};
|
||||
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, mediaExchange);
|
||||
|
||||
const result = await fixture.service.processConversation(incoming(1, 'got any pics?'));
|
||||
|
||||
expect(result?.kind).toBe('sent');
|
||||
expect(fixture.rateLimiter.throttle).toHaveBeenCalledOnce();
|
||||
expect(mediaExchange.handle).toHaveBeenCalledWith('room-alice', 'in-1', 'got any pics?');
|
||||
expect(fixture.ai.generateReply).not.toHaveBeenCalled();
|
||||
expect(fixture.getSaved()).toMatchObject({ askedForMedia: true, automation: { turnsUsed: 0, status: 'active' } });
|
||||
});
|
||||
|
||||
it('allows only one autonomous media request and then hands off', async () => {
|
||||
const { service, chat, getSaved } = harness();
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaLibraryPage } from '../../apps/dashboard/src/features/media-library/media-library-page.js';
|
||||
|
||||
const asset = {
|
||||
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'], safety: 'sfw',
|
||||
classificationStatus: 'ready', description: 'A face-led self portrait.', confidence: 0.91,
|
||||
fileName: 'portrait.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 640, height: 480,
|
||||
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
||||
sendCount: 0, lastSentAt: null,
|
||||
urlPath: '/library-media/portrait.jpg',
|
||||
};
|
||||
const server = setupServer();
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('exchange library page', () => {
|
||||
it('keeps images blurred and saves operator tag corrections', async () => {
|
||||
let update: unknown;
|
||||
server.use(
|
||||
http.get('/api/media-library', () => HttpResponse.json([asset])),
|
||||
http.patch('/api/media-library/:id', async ({ request }) => {
|
||||
update = await request.json();
|
||||
return HttpResponse.json({ ...asset, ...(update as object) });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
|
||||
expect(image).toHaveClass('is-blurred');
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal' }));
|
||||
expect(image).not.toHaveClass('is-blurred');
|
||||
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
|
||||
});
|
||||
|
||||
it('requires consent in the UI and uploads the selected image for classification', async () => {
|
||||
let consentHeader: string | null = null;
|
||||
server.use(
|
||||
http.get('/api/media-library', () => HttpResponse.json([])),
|
||||
http.post('/api/media-library', ({ request }) => {
|
||||
consentHeader = request.headers.get('x-consent-confirmed');
|
||||
return HttpResponse.json(asset, { status: 201 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await screen.findByText('No exchange images yet.');
|
||||
const upload = screen.getByRole('button', { name: 'Upload and classify' });
|
||||
expect(upload).toBeDisabled();
|
||||
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
|
||||
expect(upload).toBeDisabled();
|
||||
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
|
||||
expect(upload).toBeEnabled();
|
||||
await user.click(upload);
|
||||
await screen.findByText('Image added. Review its tags before relying on it.');
|
||||
expect(consentHeader).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -107,10 +107,12 @@ describe('Database Client', () => {
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||
expect(tables.length).toBe(18);
|
||||
expect(tables.length).toBe(20);
|
||||
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||
'candidate_queue',
|
||||
'runtime_state',
|
||||
'media_library_assets',
|
||||
'media_library_sends',
|
||||
]));
|
||||
});
|
||||
|
||||
@@ -160,7 +162,7 @@ describe('Database Client', () => {
|
||||
DROP TABLE conversation_automation_claims;
|
||||
DROP TABLE ai_usage;
|
||||
`);
|
||||
db.pragma(`user_version = ${latestVersion - 6}`);
|
||||
db.pragma(`user_version = ${latestVersion - 7}`);
|
||||
runMigrations(db);
|
||||
|
||||
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
||||
@@ -197,7 +199,7 @@ describe('Database Client', () => {
|
||||
'durable reply', 5, 11, 1
|
||||
);
|
||||
`);
|
||||
db.pragma(`user_version = ${latestVersion - 1}`);
|
||||
db.pragma(`user_version = ${latestVersion - 2}`);
|
||||
|
||||
runMigrations(db);
|
||||
|
||||
|
||||
@@ -14,6 +14,38 @@ const creds = {
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
describe('MatrixClient outbound image media', () => {
|
||||
it('uploads raw bytes and sends an m.image event with a stable transaction ID', async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/upload')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ content_uri: 'mxc://reddit.com/image-one' }), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ event_id: '$image-event' }), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
}) as typeof fetch;
|
||||
const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl });
|
||||
|
||||
const mxc = await client.uploadImage(Buffer.from([1, 2, 3]), 'image/jpeg', 'portrait.jpg');
|
||||
await expect(client.sendImageMessage(
|
||||
'!room:reddit.com', 'portrait.jpg', mxc,
|
||||
{ mimeType: 'image/jpeg', size: 3, width: 10, height: 20 }, 'stable-image-txn',
|
||||
)).resolves.toBe('$image-event');
|
||||
|
||||
expect(calls[0].url).toContain('/_matrix/media/v3/upload?filename=portrait.jpg');
|
||||
expect(new Headers(calls[0].init?.headers).get('content-type')).toBe('image/jpeg');
|
||||
expect(calls[1].url).toContain('/stable-image-txn');
|
||||
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
|
||||
msgtype: 'm.image', body: 'portrait.jpg', url: 'mxc://reddit.com/image-one',
|
||||
info: { mimetype: 'image/jpeg', size: 3, w: 10, h: 20 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient HTTP timeout', () => {
|
||||
it('loads an encoded Matrix user profile with bearer authentication', async () => {
|
||||
let requestedUrl = '';
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaLibraryRepository } from '../src/database/media-library-repository.js';
|
||||
import { detectMediaRequest, MediaLibraryExchangeService } from '../src/services/media-library-exchange.js';
|
||||
import type { MediaLibraryTag } from '../src/services/media-library-types.js';
|
||||
import { createTestDb } from './testUtils.js';
|
||||
|
||||
describe('media library request detection', () => {
|
||||
it.each([
|
||||
['send a dick pic', 'penis'],
|
||||
['can I see you topless?', 'topless'],
|
||||
['got a selfie?', 'selfie'],
|
||||
['what do you look like?', 'generic'],
|
||||
['got any pics?', 'generic'],
|
||||
])('classifies %s as %s', (message, expected) => {
|
||||
expect(detectMediaRequest(message)).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not treat an unrelated show-me instruction as an image request', () => {
|
||||
expect(detectMediaRequest('show me how this works')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaLibraryExchangeService', () => {
|
||||
let directory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'redditbot-exchange-'));
|
||||
});
|
||||
afterEach(async () => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
it('starts generic requests at SFW, broadens progressively, and never reuses an asset', async () => {
|
||||
const db = createTestDb();
|
||||
const repository = new MediaLibraryRepository(db);
|
||||
await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1);
|
||||
await addAsset(repository, directory, 'feet', ['feet'], 'sfw', 2);
|
||||
await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 3);
|
||||
await addAsset(repository, directory, 'penis', ['penis'], 'nsfw', 4);
|
||||
const sentAssets: string[] = [];
|
||||
const chat = {
|
||||
sendChatImage: vi.fn(async (_room: string, image: { fileName: string }) => {
|
||||
sentAssets.push(image.fileName.split('.')[0]);
|
||||
return { eventId: `$${sentAssets.length}`, mxcUri: `mxc://test/${sentAssets.length}` };
|
||||
}),
|
||||
sendChatMessage: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory, false, () => 0);
|
||||
|
||||
expect((await service.handle('room', 'one', 'got any pics?')).kind).toBe('sent');
|
||||
expect(sentAssets[0]).toMatch(/selfie|feet/);
|
||||
expect((await service.handle('room', 'two', 'more')).kind).toBe('sent');
|
||||
expect(sentAssets[1]).not.toBe(sentAssets[0]);
|
||||
expect(sentAssets[1]).not.toBe('penis');
|
||||
expect((await service.handle('room', 'three', 'show me your penis')).kind).toBe('sent');
|
||||
expect(sentAssets[2]).toBe('penis');
|
||||
expect(new Set(sentAssets).size).toBe(sentAssets.length);
|
||||
expect(chat.sendChatImage.mock.invocationCallOrder[0]).toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
|
||||
expect(repository.get('penis')).toMatchObject({ sendCount: 1, lastSentAt: expect.any(Number) });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('escalates instead of substituting or reusing when no exact match remains', async () => {
|
||||
const db = createTestDb();
|
||||
const repository = new MediaLibraryRepository(db);
|
||||
await addAsset(repository, directory, 'only-penis', ['penis'], 'nsfw', 1, 'same-content');
|
||||
const chat = {
|
||||
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$one', mxcUri: 'mxc://one' }),
|
||||
sendChatMessage: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
|
||||
expect((await service.handle('room', 'one', 'penis pic')).kind).toBe('sent');
|
||||
repository.softDelete('only-penis');
|
||||
await addAsset(repository, directory, 'reuploaded-penis', ['penis'], 'nsfw', 2, 'same-content');
|
||||
expect(await service.handle('room', 'two', 'another penis pic')).toMatchObject({ kind: 'human-required' });
|
||||
expect(chat.sendChatImage).toHaveBeenCalledOnce();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
async function addAsset(
|
||||
repository: MediaLibraryRepository,
|
||||
directory: string,
|
||||
id: string,
|
||||
tags: MediaLibraryTag[],
|
||||
safety: 'sfw' | 'nsfw',
|
||||
createdAt: number,
|
||||
sha256 = `hash-${id}`,
|
||||
) {
|
||||
const relativePath = `${id}.jpg`;
|
||||
await fs.writeFile(path.join(directory, relativePath), Buffer.from(id));
|
||||
repository.create({
|
||||
id, tags, safety, classificationStatus: 'ready', description: id, confidence: 1,
|
||||
fileName: relativePath, relativePath, mimeType: 'image/jpeg', sizeBytes: id.length,
|
||||
width: 10, height: 10, sha256, enabled: true,
|
||||
consentConfirmedAt: createdAt, createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
function logger() {
|
||||
return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as never;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaLibraryRepository } from '../src/database/media-library-repository.js';
|
||||
import { MediaLibraryConflictError, MediaLibraryService } from '../src/services/media-library.js';
|
||||
import { createTestDb } from './testUtils.js';
|
||||
|
||||
describe('MediaLibraryService', () => {
|
||||
let directory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'redditbot-library-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('re-encodes uploads without metadata and stores editable AI classifications', async () => {
|
||||
const db = createTestDb();
|
||||
const repository = new MediaLibraryRepository(db);
|
||||
const classifyLibraryImage = vi.fn().mockResolvedValue({
|
||||
tags: ['selfie', 'topless'], safety: 'nsfw', description: 'A self portrait.', confidence: 0.92,
|
||||
});
|
||||
const service = new MediaLibraryService(repository, { classifyLibraryImage }, logger(), directory, () => 1234);
|
||||
const source = await sharp({ create: { width: 8, height: 6, channels: 3, background: '#cc8844' } })
|
||||
.withMetadata({ orientation: 6 })
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
|
||||
const asset = await service.upload({
|
||||
bytes: source,
|
||||
contentType: 'image/jpeg',
|
||||
originalFileName: '../private portrait.jpg',
|
||||
consentConfirmed: true,
|
||||
});
|
||||
|
||||
expect(asset).toMatchObject({
|
||||
tags: ['selfie', 'topless'], safety: 'nsfw', classificationStatus: 'ready', enabled: true,
|
||||
consentConfirmedAt: 1234,
|
||||
});
|
||||
expect(classifyLibraryImage).toHaveBeenCalledOnce();
|
||||
const outputMetadata = await sharp(service.resolvePath(asset)).metadata();
|
||||
expect(outputMetadata.exif).toBeUndefined();
|
||||
expect(outputMetadata.orientation).toBeUndefined();
|
||||
|
||||
expect(service.update(asset.id, { tags: ['selfie'], safety: 'sfw', enabled: false }))
|
||||
.toMatchObject({ tags: ['selfie'], safety: 'sfw', enabled: false });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('keeps failed or low-confidence classifications disabled and rejects duplicate content', async () => {
|
||||
const db = createTestDb();
|
||||
const repository = new MediaLibraryRepository(db);
|
||||
const source = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#123456' } }).png().toBuffer();
|
||||
const classifier = { classifyLibraryImage: vi.fn().mockRejectedValue(new Error('refused')) };
|
||||
const service = new MediaLibraryService(repository, classifier, logger(), directory);
|
||||
|
||||
const first = await service.upload({ bytes: source, contentType: 'image/png', originalFileName: 'one.png', consentConfirmed: true });
|
||||
expect(first).toMatchObject({ classificationStatus: 'failed', safety: 'unknown', tags: [], enabled: false });
|
||||
await expect(service.upload({ bytes: source, contentType: 'image/png', originalFileName: 'two.png', consentConfirmed: true }))
|
||||
.rejects.toBeInstanceOf(MediaLibraryConflictError);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
function logger() {
|
||||
return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as never;
|
||||
}
|
||||
@@ -30,6 +30,18 @@ describe('OpenRouterClient', () => {
|
||||
client = new OpenRouterClient(config, logger, mockOpenAI);
|
||||
});
|
||||
|
||||
it('parses strict multi-tag media-library classifications', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
model: 'vision-model',
|
||||
choices: [{ message: { content: '```json\n{"tags":["selfie","topless"],"safety":"nsfw","description":"A self portrait","confidence":0.88}\n```' } }],
|
||||
usage: {},
|
||||
});
|
||||
|
||||
await expect(client.classifyLibraryImage('base64data', 'image/jpeg')).resolves.toEqual({
|
||||
tags: ['selfie', 'topless'], safety: 'nsfw', description: 'A self portrait', confidence: 0.88,
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRefusal (internal)', () => {
|
||||
// We test refusal detection by checking the analyzeImage behavior
|
||||
// since isRefusal is private. We do this by mocking responses that
|
||||
|
||||
@@ -24,6 +24,8 @@ function createWorkspace(includeReactBuild = true): string {
|
||||
fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '<h1>Legacy dashboard</h1>');
|
||||
fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media');
|
||||
fs.mkdirSync(path.join(workspace, 'data', 'media-library'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, 'data', 'media-library', 'library.txt'), 'private library media');
|
||||
if (includeReactBuild) {
|
||||
fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '<h1>React dashboard</h1><script src="/assets/app.js"></script>');
|
||||
@@ -81,19 +83,22 @@ describe('authenticated React dashboard cutover', () => {
|
||||
fetch(`${running.baseUrl}/api/stats`),
|
||||
fetch(`${running.baseUrl}/api/events/stream`),
|
||||
fetch(`${running.baseUrl}/media/sample.txt`),
|
||||
fetch(`${running.baseUrl}/library-media/library.txt`),
|
||||
fetch(running.baseUrl, { redirect: 'manual' }),
|
||||
]);
|
||||
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 302]);
|
||||
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 401, 302]);
|
||||
|
||||
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
||||
const api = await fetch(`${running.baseUrl}/api/stats`, { headers });
|
||||
const media = await fetch(`${running.baseUrl}/media/sample.txt`, { headers });
|
||||
const libraryMedia = await fetch(`${running.baseUrl}/library-media/library.txt`, { headers });
|
||||
const root = await fetch(running.baseUrl, { headers });
|
||||
const asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers });
|
||||
const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } });
|
||||
|
||||
expect(api.status).toBe(200);
|
||||
expect(await media.text()).toBe('private media');
|
||||
expect(await libraryMedia.text()).toBe('private library media');
|
||||
expect(await root.text()).toContain('React dashboard');
|
||||
expect(await asset.text()).toBe('react-build');
|
||||
expect(await spa.text()).toContain('React dashboard');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user