Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app/agents/agents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { EventsModule } from '../events/events.module';
import { SharedModule } from '../shared/shared.module';
import { AgentsController } from './agents.controller';
import { AgentsWebhookController } from './agents-webhook.controller';
import { AgentAttachmentStorage } from './services/agent-attachment-storage.service';
import { AgentConfigResolver } from './services/agent-config-resolver.service';
import { AgentConversationService } from './services/agent-conversation.service';
import { AgentInboundHandler } from './services/agent-inbound-handler.service';
Expand All @@ -28,6 +29,7 @@ import { USE_CASES } from './usecases';
ChannelEndpointRepository,
ConversationRepository,
ConversationActivityRepository,
AgentAttachmentStorage,
AgentConfigResolver,
AgentSubscriberResolver,
AgentConversationService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import type { StorageService } from '@novu/application-generic';
import { expect } from 'chai';
import type { Attachment } from 'chat';
import sinon from 'sinon';
import { AgentAttachmentStorage, READ_URL_TTL_SECONDS } from './agent-attachment-storage.service';

describe('AgentAttachmentStorage', () => {
const mb = 1024 * 1024;
const ctx = {
organizationId: 'org1',
environmentId: 'env1',
conversationId: 'conv1',
platformMessageId: 'msg1',
};

function makeLogger() {
return {
warn: sinon.stub(),
error: sinon.stub(),
debug: sinon.stub(),
info: sinon.stub(),
setContext: sinon.stub(),
};
}

function makeStorageService() {
return {
uploadFile: sinon.stub().resolves({}),
getReadSignedUrl: sinon.stub().resolves('https://signed/read'),
fileExists: sinon.stub(),
} as unknown as StorageService;
}

it('should upload and return signed url for fetchData attachment', async () => {
const uploadFile = sinon.stub().resolves({});
const getReadSignedUrl = sinon.stub().resolves('https://signed/read');
const storageService = {
uploadFile,
getReadSignedUrl,
fileExists: sinon.stub(),
} as unknown as StorageService;

const service = new AgentAttachmentStorage(storageService, makeLogger() as any);

const attachment: Attachment = {
type: 'file',
name: 'doc.pdf',
mimeType: 'application/pdf',
size: 10,
fetchData: async () => Buffer.from('hello'),
};

const result = await service.storeInbound([attachment], ctx);

expect(result).to.have.length(1);
expect(result[0].url).to.equal('https://signed/read');
expect(result[0].storageKey).to.include('org1/env1/agents/conv1/msg1/0-doc.pdf');
expect(uploadFile.calledOnce).to.equal(true);
expect(getReadSignedUrl.calledOnce).to.equal(true);
expect(getReadSignedUrl.firstCall.args[1]).to.equal(READ_URL_TTL_SECONDS);
});

it('should keep uploaded attachment metadata when signing fails', async () => {
const uploadFile = sinon.stub().resolves({});
const getReadSignedUrl = sinon.stub().rejects(new Error('signing unavailable'));
const storageService = {
uploadFile,
getReadSignedUrl,
fileExists: sinon.stub(),
} as unknown as StorageService;
const logger = makeLogger();

const service = new AgentAttachmentStorage(storageService, logger as any);

const attachment: Attachment = {
type: 'file',
name: 'doc.pdf',
mimeType: 'application/pdf',
size: 10,
fetchData: async () => Buffer.from('hello'),
};

const result = await service.storeInbound([attachment], ctx);

expect(result).to.have.length(1);
expect(result[0]).to.include({
type: 'file',
name: 'doc.pdf',
mimeType: 'application/pdf',
size: 10,
});
expect(result[0].storageKey).to.include('org1/env1/agents/conv1/msg1/0-doc.pdf');
expect(result[0].url).to.equal(undefined);
expect(uploadFile.calledOnce).to.equal(true);
expect(getReadSignedUrl.calledOnce).to.equal(true);
expect(logger.warn.calledOnce).to.equal(true);
});

it('should process at most 15 inbound attachments and preserve original indexes', async () => {
const storageService = makeStorageService();
const logger = makeLogger();
const service = new AgentAttachmentStorage(storageService, logger as any);
const fetchDataStubs = Array.from({ length: 16 }, () => sinon.stub().resolves(Buffer.from('x')));
const attachments = fetchDataStubs.map((fetchData, index) => ({
type: 'file',
name: `file-${index}.txt`,
mimeType: 'text/plain',
size: 1,
fetchData,
})) as Attachment[];

const result = await service.storeInbound(attachments, ctx);

expect(result).to.have.length(15);
expect(storageService.uploadFile.callCount).to.equal(15);
expect(fetchDataStubs[15].called).to.equal(false);
expect(result[14].storageKey).to.include('org1/env1/agents/conv1/msg1/14-file-14.txt');
expect(logger.warn.calledWithMatch({ attachmentCount: 16, cap: 15 })).to.equal(true);
});

it('should skip known-size attachments that would exceed the aggregate byte cap before fetch', async () => {
const storageService = makeStorageService();
const logger = makeLogger();
const service = new AgentAttachmentStorage(storageService, logger as any);
const fetchDataStubs = [
sinon.stub().resolves(Buffer.from('a')),
sinon.stub().resolves(Buffer.from('b')),
sinon.stub().resolves(Buffer.from('c')),
];
const attachments = fetchDataStubs.map((fetchData, index) => ({
type: 'file',
name: `known-${index}.txt`,
mimeType: 'text/plain',
size: 20 * mb,
fetchData,
})) as Attachment[];

const result = await service.storeInbound(attachments, ctx);

expect(result).to.have.length(2);
expect(storageService.uploadFile.callCount).to.equal(2);
expect(fetchDataStubs[2].called).to.equal(false);
expect(logger.warn.calledWithMatch({ size: 20 * mb, aggregateCap: 50 * mb })).to.equal(true);
});

it('should skip fetchData attachments without size metadata before downloading', async () => {
const storageService = makeStorageService();
const logger = makeLogger();
const service = new AgentAttachmentStorage(storageService, logger as any);
const fetchData = sinon.stub().resolves(Buffer.from('x'));
const attachments = [{ type: 'file', name: 'unknown.bin', fetchData }] as Attachment[];

const result = await service.storeInbound(attachments, ctx);

expect(result).to.have.length(0);
expect(fetchData.called).to.equal(false);
expect(storageService.uploadFile.called).to.equal(false);
expect(logger.warn.called).to.equal(true);
});

it('should skip blob attachments when trusted size metadata is missing', async () => {
const storageService = makeStorageService();
const logger = makeLogger();
const service = new AgentAttachmentStorage(storageService, logger as any);
const blob = new Blob([Buffer.from('x')]);
const attachment = {
type: 'file',
name: 'blob.bin',
data: blob,
} as Attachment;

const result = await service.storeInbound([attachment], ctx);

expect(result).to.have.length(0);
expect(storageService.uploadFile.called).to.equal(false);
expect(logger.warn.called).to.equal(true);
});

it('should skip attachments that exceed aggregate cap after fetch when size metadata is inaccurate', async () => {
const storageService = makeStorageService();
const logger = makeLogger();
const service = new AgentAttachmentStorage(storageService, logger as any);
const attachments = [
{
type: 'file',
name: 'file-0.bin',
size: 24 * mb,
fetchData: async () => Buffer.alloc(24 * mb),
},
{
type: 'file',
name: 'file-1.bin',
size: 25 * mb,
fetchData: async () => Buffer.alloc(25 * mb),
},
{
type: 'file',
name: 'file-2.bin',
size: 1,
fetchData: async () => Buffer.alloc(2 * mb),
},
] as Attachment[];

const result = await service.storeInbound(attachments, ctx);

expect(result).to.have.length(2);
expect(storageService.uploadFile.callCount).to.equal(2);
expect(logger.warn.calledWithMatch({ byteLength: 2 * mb, aggregateCap: 50 * mb })).to.equal(true);
});

it('should skip attachment over pre-fetch size limit', async () => {
const storageService = {
uploadFile: sinon.stub(),
getReadSignedUrl: sinon.stub(),
fileExists: sinon.stub(),
} as unknown as StorageService;

const logger = makeLogger();
const service = new AgentAttachmentStorage(storageService, logger as any);

const attachment: Attachment = {
type: 'file',
size: 26 * 1024 * 1024,
fetchData: async () => Buffer.from('x'),
};

const result = await service.storeInbound([attachment], ctx);

expect(result).to.have.length(0);
expect(storageService.uploadFile.called).to.equal(false);
expect(logger.warn.calledOnce).to.equal(true);
});

it('should skip attachment over post-fetch size limit when size metadata is inaccurate', async () => {
const storageService = {
uploadFile: sinon.stub(),
getReadSignedUrl: sinon.stub(),
fileExists: sinon.stub(),
} as unknown as StorageService;

const logger = makeLogger();
const service = new AgentAttachmentStorage(storageService, logger as any);

const huge = Buffer.alloc(26 * 1024 * 1024);
const attachment: Attachment = {
type: 'file',
size: 1,
fetchData: async () => huge,
};

const result = await service.storeInbound([attachment], ctx);

expect(result).to.have.length(0);
expect(storageService.uploadFile.called).to.equal(false);
});

it('should signRead when object exists', async () => {
const storageService = {
fileExists: sinon.stub().resolves(true),
getReadSignedUrl: sinon.stub().resolves('https://read'),
} as unknown as StorageService;

const service = new AgentAttachmentStorage(storageService, makeLogger() as any);
const url = await service.signRead('org/env/agents/conv/msg/0-f.txt');

expect(url).to.equal('https://read');
expect(storageService.fileExists.calledOnce).to.equal(true);
});

it('should return null from signRead when object missing', async () => {
const storageService = {
fileExists: sinon.stub().resolves(false),
getReadSignedUrl: sinon.stub(),
} as unknown as StorageService;

const service = new AgentAttachmentStorage(storageService, makeLogger() as any);
const url = await service.signRead('missing-key');

expect(url).to.equal(null);
expect(storageService.getReadSignedUrl.called).to.equal(false);
});
});
Loading
Loading