120 lines
5.8 KiB
TypeScript
120 lines
5.8 KiB
TypeScript
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<string, string> =>
|
|
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');
|
|
});
|
|
});
|
|
});
|