Files
TREK/server/tests/unit/nest/assistant/assistant-attachments.service.test.ts
T
Maurice 2d1da19152 fix(assistant): the step that keeps an attached file's bytes is called stash
lint:test-sql counts every .prepare( under tests/ as a raw statement, and the
attachments service's prepare() was called five times in its unit test. The
test holds no SQL; the method takes a name that does not read as one.
2026-10-24 22:32:48 +02:00

263 lines
12 KiB
TypeScript

/**
* The files users attach in the trip chat, kept with the conversation: the
* bytes go to storage before the turn writes anything, the text TREK reads
* stays in the row and never fails the turn, a thread's bytes go with it, and
* the nightly sweep removes the week-old ones and the bytes no row names.
*/
import { AssistantAttachments } from '../../../../src/db/entities/AssistantAttachments.entity';
import {
ASSISTANT_ATTACHMENT_TTL_DAYS,
AssistantAttachmentsService,
} from '../../../../src/nest/assistant/assistant-attachments.service';
import { AssistantKeepFileService } from '../../../../src/nest/assistant/assistant-keep-file.service';
import { LlmParseService } from '../../../../src/nest/llm-parse/llm-parse.service';
import { StorageService } from '../../../../src/nest/storage/storage.service';
import { MikroORM } from '@mikro-orm/core';
import { getRepositoryToken } from '@mikro-orm/nestjs';
import { Test } from '@nestjs/testing';
import { Readable } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
const NOW = new Date('2026-10-24T03:45:00.000Z');
const FRESH = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
const SHARED = '1c8d3f1f-7a7e-4b9f-8c2b-4e3d2f1a0b98';
const SCAN = '2d9e4a2a-8b8f-4cab-9d3c-5f4e3a2b1c09';
const ELSEWHERE = '3eaf5b3b-9c9a-4dbc-8e4d-6a5f4b3c2d10';
/** The rows findInThread answers: a file not shared yet, one shared, one without text. */
const KEPT = [
{ id: FRESH, name: 'AirBnB Buchung.pdf', mime: 'application/pdf', text: 'Casa Lisboa', shared_at: null },
{
id: SHARED,
name: 'ticket.pdf',
mime: 'application/pdf',
text: 'Casa Lisboa, HM4X2',
shared_at: '2026-10-24 08:00:00',
},
{ id: SCAN, name: 'scan.pdf', mime: 'application/pdf', text: null, shared_at: '2026-10-24 08:00:00' },
];
const HOUR = 60 * 60_000;
function file(name: string, content = 'Booking ABC123', mimetype = 'application/pdf'): Express.Multer.File {
const buffer = Buffer.from(content);
return { originalname: name, buffer, size: buffer.length, mimetype } as Express.Multer.File;
}
interface Listed {
key: string;
mtimeMs: number;
}
async function make(opts: { text?: string | Error; listed?: Listed[]; failDelete?: string; failPut?: boolean } = {}) {
const rows = {
insertAttachment: vi.fn(async () => undefined),
listForThread: vi.fn(async () => []),
listObjectsOfThread: vi.fn(async () => [{ id: 'a1', storage_key: 'assistant/a1.pdf' }]),
listCreatedBefore: vi.fn(async () => [
{ id: 'old1', storage_key: 'assistant/old1.pdf' },
{ id: 'old2', storage_key: 'assistant/old2.pdf' },
]),
deleteByIds: vi.fn(async (ids: string[]) => ids.length),
listKnownKeys: vi.fn(async (keys: string[]) => keys.filter((key) => key.startsWith('assistant/kept'))),
findInThread: vi.fn(async (id: string) => KEPT.find((row) => row.id === id) ?? null),
listSharedTexts: vi.fn(async () => [{ id: SHARED, text: 'Casa Lisboa, HM4X2' }]),
markShared: vi.fn(async () => true),
};
const put = vi.fn(async (_category: string, _key: string, source: Readable) => {
if (opts.failPut) throw new Error('bucket is gone');
for await (const _chunk of source) {
// drained, as a driver would
}
});
const storage = {
put,
delete: vi.fn(async (_category: string, key: string) => {
if (key === opts.failDelete) throw new Error('permission denied');
}),
list: vi.fn(async function* list() {
for (const entry of opts.listed ?? []) yield { key: entry.key, size: 1, mtimeMs: entry.mtimeMs };
}),
};
const llmParse = {
readText: vi.fn(async () => {
if (opts.text instanceof Error) throw opts.text;
return opts.text ?? ' Booking ABC123 ';
}),
fitText: vi.fn((text: string, compact: boolean) => (compact ? text.slice(0, 4) : text)),
};
const moduleRef = await Test.createTestingModule({
providers: [
AssistantAttachmentsService,
{ provide: getRepositoryToken(AssistantAttachments), useValue: rows },
{ provide: StorageService, useValue: storage },
{ provide: LlmParseService, useValue: llmParse },
{ provide: MikroORM, useValue: {} },
{
provide: AssistantKeepFileService,
useValue: { keep: vi.fn(async () => ({ ok: true, value: { file: { id: 3 } } })) },
},
],
}).compile();
return { service: moduleRef.get(AssistantAttachmentsService), rows, storage, llmParse };
}
describe('AssistantAttachmentsService', () => {
it('ASTATT-001: keeps the bytes under the attachments folder and reads the text, before any row is written', async () => {
const t = await make();
const kept = await t.service.stash(file('AirBnB Buchung.pdf'), 'AirBnB Buchung.pdf');
expect(kept).toMatchObject({
name: 'AirBnB Buchung.pdf',
mime: 'application/pdf',
size: 14,
text: 'Booking ABC123',
textStatus: 'ok',
});
expect(kept.id).toMatch(/^[0-9a-f-]{36}$/);
expect(kept.storageKey).toBe(`assistant/${kept.id}.pdf`);
expect(kept.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(t.storage.put).toHaveBeenCalledWith('files', kept.storageKey, expect.any(Readable), {
contentType: 'application/pdf',
});
expect(t.rows.insertAttachment).not.toHaveBeenCalled();
});
it('ASTATT-002: a storage that refuses the bytes refuses the turn', async () => {
const t = await make({ failPut: true });
await expect(t.service.stash(file('a.pdf'), 'a.pdf')).rejects.toThrow('bucket is gone');
});
it('ASTATT-003: says how reading the text went, and a failure never fails the file', async () => {
const statusOf = async (name: string, text?: string | Error) => {
const t = await make({ text });
return (await t.service.stash(file(name), name)).textStatus;
};
expect(await statusOf('scan.pdf', ' ')).toBe('scanned');
expect(await statusOf('empty.txt', '')).toBe('empty');
expect(await statusOf('broken.pdf', new Error('bad xref'))).toBe('failed');
expect(await statusOf('photo.JPG')).toBe('unsupported');
expect(await statusOf('pass.pkpass')).toBe('unsupported');
expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('bad xref'));
});
it('ASTATT-004: a name with an odd extension gets a plain key, and a long text is kept to its cap', async () => {
const t = await make({ text: 'x'.repeat(250_000) });
const kept = await t.service.stash(file('ticket.p d f'), 'ticket.p d f');
expect(kept.storageKey).toBe(`assistant/${kept.id}`);
expect(kept.text).toHaveLength(200_000);
});
it('ASTATT-005: records the row in the thread the turn opened, and drops the bytes of a turn that never began', async () => {
const t = await make();
const kept = await t.service.stash(file('a.pdf'), 'a.pdf');
await t.service.record(kept, { threadId: 4, userId: 7, tripId: 9 });
expect(t.rows.insertAttachment).toHaveBeenCalledWith(
expect.objectContaining({ id: kept.id, threadId: 4, userId: 7, tripId: 9, storageKey: kept.storageKey }),
);
await t.service.discard(kept);
expect(t.storage.delete).toHaveBeenCalledWith('files', kept.storageKey);
});
it('ASTATT-006: a deleted thread takes its bytes along; one that cannot go now is left for the sweep', async () => {
const t = await make({ failDelete: 'assistant/a1.pdf' });
const objects = await t.service.objectsOfThread(4, 7);
expect(t.rows.listObjectsOfThread).toHaveBeenCalledWith(4, 7);
await expect(t.service.dropObjects(objects)).resolves.toBeUndefined();
expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('assistant/a1.pdf'));
});
it(`ASTATT-007: the sweep removes what is ${ASSISTANT_ATTACHMENT_TTL_DAYS} days old, bytes first, then loose bytes`, async () => {
const t = await make({
failDelete: 'assistant/old2.pdf',
listed: [
{ key: 'assistant/kept.pdf', mtimeMs: NOW.getTime() - 3 * HOUR },
{ key: 'assistant/loose.pdf', mtimeMs: NOW.getTime() - 3 * HOUR },
{ key: 'assistant/fresh.pdf', mtimeMs: NOW.getTime() - 60_000 },
],
});
const swept = await t.service.sweep(NOW);
expect(t.rows.listCreatedBefore).toHaveBeenCalledWith('2026-10-17 03:45:00', 500);
// The row whose bytes could not be deleted stays, so it can be tried again.
expect(t.rows.deleteByIds).toHaveBeenCalledWith(['old1']);
expect(t.rows.listKnownKeys).toHaveBeenCalledWith(['assistant/kept.pdf', 'assistant/loose.pdf']);
expect(t.storage.delete).toHaveBeenCalledWith('files', 'assistant/loose.pdf');
expect(t.storage.delete).not.toHaveBeenCalledWith('files', 'assistant/kept.pdf');
expect(t.storage.delete).not.toHaveBeenCalledWith('files', 'assistant/fresh.pdf');
expect(swept).toEqual({ expired: 1, orphaned: 1 });
expect(logMock.logInfo).toHaveBeenCalledWith(
'Assistant retention: removed 1 attached file(s) and 1 loose object(s)',
);
});
it('ASTATT-008: lists a thread for the user only', async () => {
const t = await make();
await t.service.listForThread(4, 7);
expect(t.rows.listForThread).toHaveBeenCalledWith(4, 7);
});
it('ASTATT-009: the consent is kept on the file, and the answer names the file, never its text', async () => {
const t = await make();
const run = await t.service.share(7, 4, { attachmentId: FRESH });
expect(run).toEqual({ ok: true, value: { name: 'AirBnB Buchung.pdf', mime: 'application/pdf', read: true } });
expect(t.rows.findInThread).toHaveBeenCalledWith(FRESH, 4, 7);
expect(t.rows.markShared).toHaveBeenCalledWith(FRESH, 4, 7, expect.stringMatching(/^\d{4}-\d{2}-\d{2} /));
expect(JSON.stringify(run)).not.toContain('Casa');
expect(await t.service.share(7, 4, { attachmentId: ELSEWHERE })).toEqual({
ok: false,
value: 'No such attachment in this conversation.',
});
expect(await t.service.share(7, 4, { attachmentId: SCAN })).toMatchObject({ ok: false });
expect(await t.service.applyChange('read_attached_file', { userId: 7, threadId: 4, tripId: 9 }, {})).toEqual({
ok: false,
value: 'No such attachment in this conversation.',
});
expect(t.service.handles('read_attached_file')).toBe(true);
expect(t.service.handles('create_place')).toBe(false);
const at = { userId: 7, threadId: 4, tripId: 9 };
expect(await t.service.applyChange('keep_attached_file', at, { attachmentId: FRESH })).toEqual({
ok: true,
value: { file: { id: 3 } },
});
expect(await t.service.applyChange('create_place', at, {})).toEqual({
ok: false,
value: 'Unknown tool: create_place',
});
});
it('ASTATT-010: a shared file is read for the model only; one not shared yet is not read', async () => {
const t = await make();
const run = await t.service.readShared(7, 4, SHARED, false);
expect(run).toEqual({
ok: true,
value: { name: 'ticket.pdf', mime: 'application/pdf', read: true },
modelText: JSON.stringify({
name: 'ticket.pdf',
mime: 'application/pdf',
read: true,
text: 'Casa Lisboa, HM4X2',
}),
});
expect((await t.service.readShared(7, 4, SHARED, true)).modelText).toContain('"text":"Casa"');
expect(await t.service.readShared(7, 4, FRESH, false)).toEqual({
ok: false,
value: 'The user has not let you read this file yet.',
});
expect(await t.service.readShared(7, 4, ELSEWHERE, false)).toMatchObject({ ok: false });
expect(await t.service.readShared(7, 4, SCAN, false)).toMatchObject({ ok: false });
});
it("ASTATT-011: a turn gets the thread's files and the shared texts, cut for its connection", async () => {
const t = await make();
const custom = await t.service.turnFor(7, 4, { kind: 'custom', model: 'llama', host: 'gpu.lan' });
expect(custom.texts).toEqual(new Map([[SHARED, 'Casa']]));
expect(custom.turn.sentTo).toBe('llama (gpu.lan)');
const cloud = await t.service.turnFor(7, 4, { kind: 'openai', model: 'gpt-5', host: 'api.openai.com' });
expect(cloud.texts).toEqual(new Map([[SHARED, 'Casa Lisboa, HM4X2']]));
expect(t.rows.listSharedTexts).toHaveBeenCalledWith(4, 7);
});
});