xpeditis2.0/apps/backend/src/application/trade-assistant/trade-assistant.service.spec.ts
David c35f3d7bfb feat(ia): fait valider par un administrateur toute page ecrite par l'assistant
L'assistant publiait directement dans le wiki global. La politique de contenu
ecarte la faute franche — conseil FCL, cas client, hors perimetre — mais une
heuristique ne juge pas la justesse : une page fausse mais bien ecrite la
franchissait, et se retrouvait citee comme documentation Xpeditis aupres de
tous les clients.

Domaine
- `WikiContributionStatus` : pending / published / rejected. `create()` ne
  prend pas le statut en parametre — rien ne nait publie.
- `publish(reviewerId, edits?)` valide, en acceptant une correction du
  relecteur, qui repasse la meme politique de contenu.
- `reject(reviewerId, note?)` ecarte sans supprimer : la liste des refus montre
  ou l'assistant se trompe.
- `revise()` remet une page validee en attente : sans cela, la validation
  porterait sur un texte que l'assistant a remplace depuis.

Ce qui sort du depot
- `findPublished` pour la recherche et la page publique, `findForReview` pour
  l'administration. Le statut est porte par la requete, pas filtre en memoire :
  une page en attente ne peut pas sortir par le chemin des clients.
- `revision()` ne compte que le publie, donc l'index vectoriel ne se reconstruit
  que sur une decision.

Relecture
- `GET /admin/wiki-contributions`, `POST :id/publish`, `POST :id/reject`, sous
  JwtAuthGuard + RolesGuard et @Roles('admin').
- Chaque decision est journalisee (`WIKI_CONTRIBUTION_REVIEWED`) : une page
  publiee engage la marque, on doit pouvoir dire qui l'a laissee passer.
- Ecran `/admin/wiki` : file par statut, corps complet affiche, correction et
  motif de refus facultatifs.

L'assistant
- La capacite rend `pending_review` et un message d'attente, plus d'URL : le
  modele annonce une proposition, jamais une publication. La consigne le lui
  dit explicitement.

SQL de la migration verifie sur la base locale : l'upsert sur index
d'expression met bien a jour a la casse pres, la contrainte de statut refuse
une valeur inconnue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:41:28 +02:00

382 lines
14 KiB
TypeScript

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<SubscriptionRepository>;
let quota: jest.Mocked<TradeQuotaPort>;
let ai: jest.Mocked<TradeAiPort>;
let retrieval: jest.Mocked<TradeRetrievalPort>;
let conversations: jest.Mocked<TradeConversationRepository>;
let wikiContributions: jest.Mocked<WikiContributionRepository>;
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);
});
});