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>
382 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|