Files
dinggadingga-backend/src/services/slug/slug.service.spec.ts

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');
});
});
});