Files
RedditBot/tests/media-library-exchange.test.ts
Jamie BruntonandClaude Opus 4.8 c8689af04d feat: decouple manual image sharing from automation gating
The "Share a saved image" panel was wired to the automated exchange
engine, so an operator could only share an image right after an inbound
image request, limited to assets the auto-exchanger would itself pick.

Add a manual path that offers any enabled, unused library image at any
point (no request detection, tier, seller, or boundary gating) and sends
it with no reciprocal "your turn" text. Safety flags now surface as a
non-blocking warning instead of disabling the panel. The automated
auto-send path keeps all its guardrails; the shared image-delivery step
is extracted into a helper used by both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 09:18:37 +01:00

199 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) });
expect(service.getOutboundAttribution('room')).toEqual({
eventIds: ['$1', '$2', '$3'],
texts: Array(3).fill('Your turn — send me one back if you’re comfortable.'),
});
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();
});
it('lists eligible choices and sends only the operator-selected matching asset', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
await addAsset(repository, directory, 'selfie-one', ['selfie'], 'sfw', 1);
await addAsset(repository, directory, 'selfie-two', ['selfie'], 'sfw', 2);
await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 3);
const chat = {
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$selected', mxcUri: 'mxc://selected' }),
sendChatMessage: vi.fn().mockResolvedValue(true),
};
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
expect(service.listShareable('room', 'send a selfie').assets.map((asset) => asset.id)).toEqual([
'selfie-two', 'selfie-one',
]);
expect(await service.handleSelected('room', '$request', 'send a selfie', 'topless')).toMatchObject({
kind: 'human-required',
});
expect(await service.handleSelected('room', '$request', 'send a selfie', 'selfie-one')).toMatchObject({
kind: 'sent', assetId: 'selfie-one', requestKind: 'selfie',
});
expect(chat.sendChatImage).toHaveBeenCalledWith(
'room', expect.objectContaining({ fileName: 'selfie-one.jpg' }), expect.any(String), null,
);
db.close();
});
it('keeps an image-only partial delivery human-required on later attempts', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1);
const chat = {
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$image', mxcUri: 'mxc://image' }),
sendChatMessage: vi.fn().mockResolvedValue(false),
};
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({
kind: 'human-required', remoteSent: true,
});
await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({
kind: 'human-required', remoteSent: true,
});
expect(chat.sendChatImage).toHaveBeenCalledOnce();
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
db.close();
});
it('manually shares any enabled image with no request gating and no reciprocal text', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1);
await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 2);
const chat = {
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$img', mxcUri: 'mxc://img' }),
sendChatMessage: vi.fn().mockResolvedValue(true),
};
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
// No image request in the conversation, yet every enabled image is offered.
expect(service.listShareableManual('room').map((asset) => asset.id).sort()).toEqual(['selfie', 'topless']);
const result = await service.shareManual('room', 'topless');
expect(result).toMatchObject({ kind: 'sent', assetId: 'topless' });
expect(chat.sendChatImage).toHaveBeenCalledOnce();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
// The shared image leaves the manual list; re-sharing it is idempotent and re-sends nothing.
expect(service.listShareableManual('room').map((asset) => asset.id)).toEqual(['selfie']);
expect(await service.shareManual('room', 'topless')).toMatchObject({ kind: 'sent', assetId: 'topless' });
expect(chat.sendChatImage).toHaveBeenCalledOnce();
db.close();
});
it('refuses a manual share of an unknown or disabled image', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1);
repository.update('selfie', ['selfie'], 'sfw', false);
const chat = {
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$img', mxcUri: 'mxc://img' }),
sendChatMessage: vi.fn().mockResolvedValue(true),
};
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
expect(await service.shareManual('room', 'does-not-exist')).toMatchObject({ kind: 'human-required' });
expect(await service.shareManual('room', 'selfie')).toMatchObject({ kind: 'human-required' });
expect(chat.sendChatImage).not.toHaveBeenCalled();
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;
}