import { NotFoundException } from '@nestjs/common'; import { WikiContribution, WikiContributionRejected, WikiContributionStatus, } from '@domain/entities/wiki-contribution.entity'; import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository'; import { UserRepository } from '@domain/ports/out/user.repository'; import { AuditAction } from '@domain/entities/audit-log.entity'; import { AuditService } from '../services/audit.service'; import { WikiReviewService } from './wiki-review.service'; const reviewer = { id: 'admin-1', email: 'admin@xpeditis.com', organizationId: 'org' }; const BODY = `La règle du 24 heures impose de transmettre le manifeste de cargaison aux douanes du pays de destination avant le chargement du navire au port d'embarquement. Elle s'applique au transport maritime international et conditionne l'autorisation de charger. Un dépôt tardif expose l'expéditeur à un refus d'embarquement et à une immobilisation du conteneur au terminal.`; const proposal = () => WikiContribution.fromPersistence({ ...WikiContribution.create({ id: 'w1', locale: 'fr', topic: 'douanes', title: 'La règle des 24 heures', section: 'Dépôt du manifeste', body: BODY, authorUserId: 'user', authorOrganizationId: 'org', }).toObject(), version: 1, }); describe('WikiReviewService', () => { let contributions: jest.Mocked; let users: jest.Mocked>; let audit: jest.Mocked>; let service: WikiReviewService; beforeEach(() => { contributions = { findPublished: jest.fn().mockResolvedValue([]), findForReview: jest.fn().mockResolvedValue([]), findById: jest.fn().mockResolvedValue(proposal()), findByTitle: jest.fn().mockResolvedValue(null), save: jest.fn().mockImplementation((c: WikiContribution) => Promise.resolve(c)), revision: jest.fn().mockResolvedValue('0:none'), }; users = { findById: jest.fn().mockResolvedValue(null) }; audit = { log: jest.fn().mockResolvedValue(undefined) }; service = new WikiReviewService( contributions, users as unknown as UserRepository, audit as unknown as AuditService ); }); /* ---------------------------------------------------------------------- */ /* File de relecture */ /* ---------------------------------------------------------------------- */ it('counts what is waiting, and passes an explicit filter through', async () => { const pending = proposal(); const published = proposal().publish(reviewer.id); contributions.findForReview.mockResolvedValue([pending, published]); const result = await service.list(); expect(contributions.findForReview).toHaveBeenCalledWith(undefined); expect(result.pending).toBe(1); expect(result.contributions).toHaveLength(2); }); it('ignores a filter it does not know, rather than returning an empty list', async () => { await service.list('whatever'); expect(contributions.findForReview).toHaveBeenCalledWith(undefined); }); it('names the reviewer, and survives an account deleted since', async () => { contributions.findForReview.mockResolvedValue([proposal().publish(reviewer.id)]); users.findById.mockRejectedValue(new Error('db down')); const [view] = (await service.list()).contributions; expect(view.reviewedBy).toBeNull(); expect(view.status).toBe(WikiContributionStatus.PUBLISHED); }); /* ---------------------------------------------------------------------- */ /* Decisions */ /* ---------------------------------------------------------------------- */ it('publishes the page and links to it', async () => { const view = await service.publish(reviewer, 'w1', { expectedVersion: 1 }); const saved: WikiContribution = contributions.save.mock.calls[0][0]; expect(saved.status).toBe(WikiContributionStatus.PUBLISHED); expect(saved.reviewedByUserId).toBe(reviewer.id); expect(view.href).toBe('/dashboard/wiki/complements#w1'); }); it('keeps the reviewer edits instead of the proposed text', async () => { const corrected = `${BODY} Le dépôt incombe au transitaire, jamais au destinataire.`; await service.publish(reviewer, 'w1', { expectedVersion: 1, body: corrected }); expect(contributions.save.mock.calls[0][0].body).toBe(corrected); }); it('refuses an edit that breaks the content policy', async () => { const advocacy = `${BODY} Au-delà de 15 m³, nous recommandons le FCL.`; await expect( service.publish(reviewer, 'w1', { expectedVersion: 1, body: advocacy }) ).rejects.toThrow(WikiContributionRejected); expect(contributions.save).not.toHaveBeenCalled(); }); it('keeps a rejected page, with its reason', async () => { const view = await service.reject(reviewer, 'w1', 1, ' Source non vérifiée '); const saved: WikiContribution = contributions.save.mock.calls[0][0]; expect(saved.status).toBe(WikiContributionStatus.REJECTED); expect(saved.reviewNote).toBe('Source non vérifiée'); // Une page non publiee n'a pas de lien : il pointerait vers du vide. expect(view.href).toBeNull(); }); it('records who decided what', async () => { await service.publish(reviewer, 'w1', { expectedVersion: 1 }); expect(audit.log).toHaveBeenCalledWith( expect.objectContaining({ action: AuditAction.WIKI_CONTRIBUTION_REVIEWED, userEmail: reviewer.email, resourceType: 'wiki_contribution', resourceId: 'w1', metadata: expect.objectContaining({ decision: 'published' }), }) ); }); it('reports an unknown page rather than failing silently', async () => { contributions.findById.mockResolvedValue(null); await expect(service.publish(reviewer, 'w1', { expectedVersion: 1 })).rejects.toThrow( NotFoundException ); }); });