import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common'; import { SlugService } from './slug.service'; import { Prisma, SlugEntityType } from '../../../prisma/generated/client'; import { SlugCandidateRow } from '../../database/slug.database.repository'; const row = (hiboutikId: number, name: string): SlugCandidateRow => ({ id: `uuid-${hiboutikId}`, hiboutikId, name }); describe('SlugService', () => { const slugDatabaseRepository = { getWithoutSlug: jest.fn(), getTakenSlugs: jest.fn(), setFirstSlug: jest.fn(), changeSlug: jest.fn(), findRedirect: jest.fn(), }; let service: SlugService; beforeAll(() => { jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); }); beforeEach(() => { jest.clearAllMocks(); slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set()); slugDatabaseRepository.setFirstSlug.mockResolvedValue(true); service = new SlugService(slugDatabaseRepository as never); }); /** Slugs given by assignMissingSlugs, by entity id. */ const assigned = (): Record => Object.fromEntries((slugDatabaseRepository.setFirstSlug.mock.calls as [string, string, string][]).map(([, id, slug]) => [id, slug])); describe('assignMissingSlugs', () => { it('gives the plain slug to the oldest of two homonyms, the Hiboutik id to the other', async () => { slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(3, 'Stray Kids'), row(8, 'STRAY KIDS')]); await service.assignMissingSlugs(SlugEntityType.BRAND); expect(assigned()).toEqual({ 'uuid-3': 'stray-kids', 'uuid-8': 'stray-kids-8' }); }); it('does not take a slug already used, by an entity or a redirect', async () => { slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['bts'])); slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(5, 'BTS')]); await service.assignMissingSlugs(SlugEntityType.BRAND); expect(assigned()).toEqual({ 'uuid-5': 'bts-5' }); }); it('suffixes a homonym category or product with its Hiboutik id', async () => { slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['albums'])); slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(4, 'Albums'), row(6, 'ATE (Digipack)')]); await service.assignMissingSlugs(SlugEntityType.CATEGORY); expect(assigned()).toEqual({ 'uuid-4': 'albums-4', 'uuid-6': 'ate-digipack' }); }); it('falls back to the type and Hiboutik id for a name without latin letters', async () => { slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(9, '방탄소년단')]); await service.assignMissingSlugs(SlugEntityType.BRAND); expect(assigned()).toEqual({ 'uuid-9': 'brand-9' }); }); it('keeps going when one slug fails, and never throws', async () => { slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(1, 'A'), row(2, 'B')]); slugDatabaseRepository.setFirstSlug.mockRejectedValueOnce(new Error('boom')); await expect(service.assignMissingSlugs(SlugEntityType.BRAND)).resolves.toBeUndefined(); expect(slugDatabaseRepository.setFirstSlug).toHaveBeenCalledTimes(2); slugDatabaseRepository.getWithoutSlug.mockRejectedValue(new Error('database down')); await expect(service.assignMissingSlugs(SlugEntityType.BRAND)).resolves.toBeUndefined(); }); }); describe('changeSlug', () => { it('rejects an invalid slug without touching the database', async () => { await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'Stray Kids')).rejects.toThrow(BadRequestException); await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'a'.repeat(81))).rejects.toThrow(BadRequestException); expect(slugDatabaseRepository.changeSlug).not.toHaveBeenCalled(); }); it('answers 404 for an unknown entity and 409 for a slug in use, even when written concurrently', async () => { slugDatabaseRepository.changeSlug.mockResolvedValueOnce('NOT_FOUND'); await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(NotFoundException); slugDatabaseRepository.changeSlug.mockResolvedValueOnce('TAKEN'); await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException); slugDatabaseRepository.changeSlug.mockRejectedValueOnce( new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { code: 'P2002', clientVersion: 'test' }), ); await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException); }); it('rethrows any other error, even one carrying a P2002 code without being a Prisma error', async () => { const notPrisma = Object.assign(new Error('boom'), { code: 'P2002' }); slugDatabaseRepository.changeSlug.mockRejectedValueOnce(notPrisma); await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toBe(notPrisma); const otherPrismaError = new Prisma.PrismaClientKnownRequestError('Record not found', { code: 'P2025', clientVersion: 'test' }); slugDatabaseRepository.changeSlug.mockRejectedValueOnce(otherPrismaError); await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toBe(otherPrismaError); }); it('accepts a valid new slug', async () => { slugDatabaseRepository.changeSlug.mockResolvedValue('CHANGED'); await expect(service.changeSlug(SlugEntityType.PRODUCT, 'uuid-1', 'stray-kids-ate')).resolves.toBeUndefined(); expect(slugDatabaseRepository.changeSlug).toHaveBeenCalledWith('PRODUCT', 'uuid-1', 'stray-kids-ate'); }); }); });