import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { TradeAssistantService, truncateTitle } from './trade-assistant.service'; import { SubscriptionRepository } from '@domain/ports/out/subscription.repository'; import { TradeAiPort, TradeConversationRepository, TradeMessage, TradePassage, TradeQuotaPort, TradeRetrievalPort, } from '@domain/ports/out/trade-assistant.port'; import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository'; import { Subscription } from '@domain/entities/subscription.entity'; import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo'; import { AskTradeAssistantDto } from './trade-assistant.controller'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; const answer = { text: 'Réponse', inputTokens: 100, outputTokens: 50 }; /** Compte courant : role sans privilege, offre portee par l'organisation. */ const actor = { id: 'user', organizationId: 'org', role: 'MANAGER' }; const admin = { ...actor, role: 'ADMIN' }; const passage = (topic: string, href: string): TradePassage => ({ id: `fr:${topic}:0`, title: topic, section: 'Section', href, text: 'Extrait du wiki.', score: 0.8, }); const conversation = { id: 'c1', title: 'Question', createdAt: '2026-09-05T10:00:00.000Z', updatedAt: '2026-09-05T10:00:00.000Z', messageCount: 0, }; const message = (role: 'user' | 'assistant', content: string): TradeMessage => ({ id: `${role}-1`, role, content, sources: [], actions: [], createdAt: '2026-09-05T10:00:00.000Z', }); describe('TradeAssistantService', () => { let service: TradeAssistantService; let subscriptions: jest.Mocked; let quota: jest.Mocked; let ai: jest.Mocked; let retrieval: jest.Mocked; let conversations: jest.Mocked; let wikiContributions: jest.Mocked; beforeEach(() => { subscriptions = { findByOrganizationId: jest.fn().mockResolvedValue(null), save: jest.fn(), findById: jest.fn(), findByStripeSubscriptionId: jest.fn(), findByStripeCustomerId: jest.fn(), findAll: jest.fn(), delete: jest.fn(), }; quota = { get: jest .fn() .mockResolvedValue({ day: '2026-09-05', resetsAt: '2026-09-05T22:00:00.000Z', used: 0 }), reserve: jest.fn().mockResolvedValue(true), release: jest.fn().mockResolvedValue(undefined), recordTokens: jest.fn().mockResolvedValue(undefined), }; ai = { isAvailable: jest.fn().mockReturnValue(true), answer: jest.fn().mockResolvedValue(answer), }; retrieval = { search: jest.fn().mockResolvedValue([]) }; conversations = { list: jest.fn().mockResolvedValue([conversation]), create: jest.fn().mockResolvedValue(conversation), find: jest.fn().mockResolvedValue(conversation), messages: jest.fn().mockResolvedValue([]), addMessage: jest .fn() .mockImplementation((_id, role: 'user' | 'assistant', content: string) => Promise.resolve(message(role, content)) ), rename: jest.fn().mockResolvedValue(undefined), remove: jest.fn().mockResolvedValue(undefined), }; wikiContributions = { findPublished: jest.fn().mockResolvedValue([]), findForReview: jest.fn().mockResolvedValue([]), findById: jest.fn().mockResolvedValue(null), findByTitle: jest.fn().mockResolvedValue(null), save: jest.fn(), revision: jest.fn().mockResolvedValue('0:none'), }; service = new TradeAssistantService( subscriptions, quota, ai, retrieval, conversations, wikiContributions ); }); /* ---------------------------------------------------------------------- */ /* Quota */ /* ---------------------------------------------------------------------- */ const onPlan = (plan: SubscriptionPlanType) => subscriptions.findByOrganizationId.mockResolvedValue( Subscription.create({ id: 's', organizationId: 'org', plan: SubscriptionPlan.fromString(plan), }) ); it.each<[SubscriptionPlanType, number]>([ ['BRONZE', 3], ['SILVER', 10], ['GOLD', 15], ['PLATINIUM', -1], ])('enforces %s quota per user', async (plan, limit) => { onPlan(plan); const result = await service.ask(actor, 'Question', 'fr'); expect(result.quota.limit).toBe(limit); expect(quota.reserve).toHaveBeenCalledWith('user', '2026-09-05', limit); expect(subscriptions.findByOrganizationId).toHaveBeenCalledWith('org'); expect(quota.recordTokens).toHaveBeenCalledWith('user', '2026-09-05', answer); }); it('never blocks Platinium, however many questions were already asked', async () => { onPlan('PLATINIUM'); quota.get.mockResolvedValue({ day: '2026-09-05', resetsAt: '', used: 4200 }); const status = await service.status(actor); expect(status.unlimited).toBe(true); expect(status.limit).toBe(-1); // `remaining` ne vaut pas 0 : cela se lirait comme un quota epuise. expect(status.remaining).toBe(-1); const result = await service.ask(actor, 'Q', 'fr'); expect(result.mode).toBe('ai'); expect(ai.answer).toHaveBeenCalled(); }); it('still meters Platinium usage, for cost tracking', async () => { onPlan('PLATINIUM'); await service.ask(actor, 'Q', 'fr'); expect(quota.reserve).toHaveBeenCalledWith('user', '2026-09-05', -1); expect(quota.recordTokens).toHaveBeenCalledWith('user', '2026-09-05', answer); }); it('gives an ADMIN the Platinium quota its own interface already shows', async () => { // L'apercu d'abonnement affiche « Platinium » a tout compte ADMIN. Sans // cette regle, l'assistant lisait l'abonnement de l'organisation — Bronze — // et n'accordait que trois questions a un utilisateur a qui le produit // annonçait partout l'offre illimitee. onPlan('BRONZE'); const status = await service.status(admin); expect(status.plan).toBe('PLATINIUM'); expect(status.unlimited).toBe(true); expect((await service.ask(admin, 'Q', 'fr')).mode).toBe('ai'); }); it('keeps the organisation plan for every other role', async () => { onPlan('BRONZE'); expect((await service.status({ ...actor, role: 'MANAGER' })).plan).toBe('BRONZE'); expect((await service.status({ ...actor, role: 'USER' })).plan).toBe('BRONZE'); expect((await service.status({ ...actor, role: undefined })).plan).toBe('BRONZE'); }); it('promotes an ADMIN even when the organisation subscription is inactive', async () => { subscriptions.findByOrganizationId.mockResolvedValue({ isActive: () => false, plan: SubscriptionPlan.fromString('SILVER'), } as never); expect((await service.status(actor)).plan).toBe('BRONZE'); expect((await service.status(admin)).plan).toBe('PLATINIUM'); }); it('falls back to the strictest plan when the stored plan is unknown', async () => { // Une offre inconnue donnait `undefined`, puis « NaN/undefined » a l'ecran. subscriptions.findByOrganizationId.mockResolvedValue({ isActive: () => true, plan: { value: 'LEGACY_TIER' }, } as never); const status = await service.status(actor); expect(status.limit).toBe(3); expect(status.remaining).toBe(3); expect(status.unlimited).toBe(false); }); it('defaults an unsubscribed dashboard account to Bronze', async () => { expect((await service.status(actor)).limit).toBe(3); }); it('does not call OpenAI when quota is exhausted', async () => { quota.get.mockResolvedValue({ day: '2026-09-05', resetsAt: '', used: 3 }); expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('guided'); expect(quota.reserve).not.toHaveBeenCalled(); expect(ai.answer).not.toHaveBeenCalled(); }); it('handles a concurrent request taking the last slot', async () => { quota.reserve.mockResolvedValue(false); expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('guided'); expect(ai.answer).not.toHaveBeenCalled(); }); it('does not consume quota without an API key', async () => { ai.isAvailable.mockReturnValue(false); expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('unavailable'); expect(quota.reserve).not.toHaveBeenCalled(); }); it('refunds provider failures on the original day', async () => { ai.answer.mockRejectedValue(new Error('timeout')); await expect(service.ask(actor, 'Q', 'fr')).rejects.toThrow(ServiceUnavailableException); expect(quota.release).toHaveBeenCalledWith('user', '2026-09-05'); expect(quota.recordTokens).not.toHaveBeenCalled(); }); it('never refunds a successful answer on accounting failure', async () => { quota.recordTokens.mockRejectedValue(new Error('database unavailable')); expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('ai'); expect(quota.release).not.toHaveBeenCalled(); }); it('returns a fresh quota when the answer crosses midnight', async () => { quota.get .mockResolvedValueOnce({ day: '2026-09-05', resetsAt: '', used: 0 }) .mockResolvedValueOnce({ day: '2026-09-06', resetsAt: '', used: 0 }); expect((await service.ask(actor, 'Q', 'fr')).quota.day).toBe('2026-09-06'); expect(quota.reserve).toHaveBeenCalledWith('user', '2026-09-05', 3); }); /* ---------------------------------------------------------------------- */ /* Conversations */ /* ---------------------------------------------------------------------- */ it('opens a conversation titled after the first question', async () => { const result = await service.ask(actor, ' Quels documents pour un LCL ? ', 'fr'); expect(conversations.create).toHaveBeenCalledWith('user', 'Quels documents pour un LCL ?'); expect(result.mode).toBe('ai'); expect(result.conversationId).toBe('c1'); expect(conversations.addMessage.mock.calls.map(call => call[1])).toEqual(['user', 'assistant']); }); it('replays the existing turns when continuing a conversation', async () => { conversations.messages.mockResolvedValue([ message('user', 'Première question'), message('assistant', 'Première réponse'), ]); await service.ask(actor, 'Et pour le FCL ?', 'fr', 'c1'); expect(conversations.create).not.toHaveBeenCalled(); expect(ai.answer).toHaveBeenCalledWith( expect.objectContaining({ question: 'Et pour le FCL ?', history: [ { role: 'user', content: 'Première question' }, { role: 'assistant', content: 'Première réponse' }, ], }) ); }); it('rejects a conversation owned by someone else before spending a question', async () => { conversations.find.mockResolvedValue(null); await expect(service.ask(actor, 'Q', 'fr', 'other')).rejects.toThrow(NotFoundException); expect(quota.reserve).not.toHaveBeenCalled(); expect(ai.answer).not.toHaveBeenCalled(); }); it('does not leave an empty conversation behind when the provider fails', async () => { ai.answer.mockRejectedValue(new Error('timeout')); await expect(service.ask(actor, 'Q', 'fr')).rejects.toThrow(ServiceUnavailableException); expect(conversations.remove).toHaveBeenCalledWith('user', 'c1'); }); it('keeps an existing conversation when the provider fails', async () => { ai.answer.mockRejectedValue(new Error('timeout')); await expect(service.ask(actor, 'Q', 'fr', 'c1')).rejects.toThrow(ServiceUnavailableException); expect(conversations.remove).not.toHaveBeenCalled(); }); it.each(['messages', 'rename', 'remove'] as const)('guards %s by owner', async method => { conversations.find.mockResolvedValue(null); const call = method === 'rename' ? service.rename('user', 'c1', 'Titre') : method === 'remove' ? service.remove('user', 'c1') : service.messages('user', 'c1'); await expect(call).rejects.toThrow(NotFoundException); }); /* ---------------------------------------------------------------------- */ /* Recherche documentaire */ /* ---------------------------------------------------------------------- */ it('passes the retrieved passages to the model and cites each page once', async () => { retrieval.search.mockResolvedValue([ passage('Douanes', '/dashboard/wiki/douanes'), passage('Douanes', '/dashboard/wiki/douanes'), passage('Incoterms', '/dashboard/wiki/incoterms'), ]); const result = await service.ask(actor, 'Code SH ?', 'fr'); expect(retrieval.search).toHaveBeenCalledWith('Code SH ?', 'fr'); expect(ai.answer).toHaveBeenCalledWith( expect.objectContaining({ passages: expect.arrayContaining([expect.any(Object)]) }) ); expect(result.sources).toEqual([ { title: 'Douanes', section: 'Section', href: '/dashboard/wiki/douanes' }, { title: 'Incoterms', section: 'Section', href: '/dashboard/wiki/incoterms' }, ]); }); it('still answers when the knowledge search fails', async () => { retrieval.search.mockRejectedValue(new Error('redis down')); const result = await service.ask(actor, 'Q', 'fr'); expect(result.mode).toBe('ai'); expect(ai.answer).toHaveBeenCalledWith(expect.objectContaining({ passages: [] })); }); }); describe('truncateTitle', () => { it('keeps a short question untouched', () => { expect(truncateTitle(' LCL ou FCL ? ')).toBe('LCL ou FCL ?'); }); it('cuts long questions on a word boundary', () => { const title = truncateTitle(`Quels documents ${'très '.repeat(20)}précisément ?`); expect(title.length).toBeLessThanOrEqual(60); expect(title).not.toMatch(/\s$/); expect(title.endsWith('trè')).toBe(false); }); }); describe('AskTradeAssistantDto', () => { it.each([' ', 'a'.repeat(2001), 42, null])('rejects invalid question %p', async question => { const dto = plainToInstance(AskTradeAssistantDto, { question }); expect((await validate(dto)).length).toBeGreaterThan(0); }); it('accepts a trimmed question and default language', async () => { const dto = plainToInstance(AskTradeAssistantDto, { question: ' LCL ? ' }); expect(await validate(dto)).toEqual([]); expect(dto.question).toBe('LCL ?'); }); it('rejects a conversation id that is not a uuid', async () => { const dto = plainToInstance(AskTradeAssistantDto, { question: 'Q', conversationId: 'nope' }); expect((await validate(dto)).length).toBeGreaterThan(0); }); });