feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection
This commit is contained in:
107
src/services/slug/slug.service.spec.ts
Normal file
107
src/services/slug/slug.service.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { SlugService } from './slug.service';
|
||||
import { 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(Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }));
|
||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/services/slug/slug.service.ts
Normal file
97
src/services/slug/slug.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { SlugEntityType } from '../../../prisma/generated/client';
|
||||
import { SlugCandidateRow, SlugDatabaseRepository } from '../../database/slug.database.repository';
|
||||
import { forEachSafely } from '../../utils/sync';
|
||||
import { MAX_SLUG_LENGTH, pickAvailableSlug, SLUG_PATTERN, slugify, withSlugSuffix } from '../../utils/slug';
|
||||
|
||||
/** Prefix of the slug of an entity whose name gives no slug (no latin letter or digit, e.g. a name in hangul). */
|
||||
const FALLBACK_PREFIX: Record<SlugEntityType, string> = {
|
||||
[SlugEntityType.BRAND]: 'brand',
|
||||
[SlugEntityType.CATEGORY]: 'category',
|
||||
[SlugEntityType.PRODUCT]: 'product',
|
||||
};
|
||||
|
||||
/**
|
||||
* Slugs (SEO URL identifiers) of brands, categories and products.
|
||||
*
|
||||
* A slug is generated once, when an entity has none, and is never regenerated: a URL must not change when the name
|
||||
* changes in Hiboutik. Generation is deterministic: entities are handled oldest first (by `hiboutikId`), so that a
|
||||
* database rebuilt from scratch gets the same slugs, the oldest of two homonyms keeping the plain slug.
|
||||
* An admin can replace a slug ({@link changeSlug}): the former one is kept as a redirect.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SlugService {
|
||||
private readonly logger = new Logger('SlugService');
|
||||
|
||||
constructor(private readonly slugDatabaseRepository: SlugDatabaseRepository) {}
|
||||
|
||||
/**
|
||||
* Gives a slug to every entity of this type that has none. Never throws: a failure is logged, the entities left
|
||||
* without a slug get one on the next sync.
|
||||
*/
|
||||
async assignMissingSlugs(type: SlugEntityType): Promise<void> {
|
||||
try {
|
||||
const [rows, taken] = await Promise.all([
|
||||
this.slugDatabaseRepository.getWithoutSlug(type),
|
||||
this.slugDatabaseRepository.getTakenSlugs(type),
|
||||
]);
|
||||
const nbFailed = await forEachSafely(
|
||||
rows,
|
||||
async (row) => {
|
||||
const candidates = this.buildCandidates(type, row);
|
||||
const slug = pickAvailableSlug(candidates, taken);
|
||||
if (slug !== candidates[0]) {
|
||||
// Often a duplicate in Hiboutik ("Stray Kids" and "STRAY KIDS"): worth a look in the back office
|
||||
this.logger.warn(`Slug "${candidates[0]}" already taken: Hiboutik ${type.toLowerCase()} ${row.hiboutikId} gets "${slug}"`);
|
||||
}
|
||||
if (await this.slugDatabaseRepository.setFirstSlug(type, row.id, slug)) taken.add(slug);
|
||||
},
|
||||
this.logger,
|
||||
(row) => `slug of Hiboutik ${type.toLowerCase()} ${row.hiboutikId}`,
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
this.logger.log(`Slugs of ${type.toLowerCase()} entities: ${rows.length - nbFailed}/${rows.length} generated, ${nbFailed} failed.`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to generate the slugs of ${type.toLowerCase()} entities`, (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate slugs by order of preference: the name ("albums"), then, when it is taken, the name suffixed with the
|
||||
* Hiboutik id ("albums-12"), unique as the id is.
|
||||
*/
|
||||
private buildCandidates(type: SlugEntityType, row: SlugCandidateRow): string[] {
|
||||
const base = slugify(row.name);
|
||||
if (!base) return [`${FALLBACK_PREFIX[type]}-${row.hiboutikId}`];
|
||||
return [base, withSlugSuffix(base, row.hiboutikId)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the slug of an entity (admin choice); its former slug keeps answering through a redirect.
|
||||
* @throws BadRequestException for an invalid slug, NotFoundException for an unknown entity,
|
||||
* ConflictException when another entity of the same type uses this slug.
|
||||
*/
|
||||
async changeSlug(type: SlugEntityType, id: string, slug: string): Promise<void> {
|
||||
if (slug.length > MAX_SLUG_LENGTH || !SLUG_PATTERN.test(slug)) {
|
||||
throw new BadRequestException(
|
||||
`A slug is made of lowercase letters, digits and single hyphens, ${MAX_SLUG_LENGTH} characters at most`,
|
||||
);
|
||||
}
|
||||
let result: Awaited<ReturnType<SlugDatabaseRepository['changeSlug']>>;
|
||||
try {
|
||||
result = await this.slugDatabaseRepository.changeSlug(type, id, slug);
|
||||
} catch (error) {
|
||||
// Unique constraint: the same slug was written at the same time by someone else
|
||||
if ((error as { code?: string })?.code === 'P2002') result = 'TAKEN';
|
||||
else throw error;
|
||||
}
|
||||
if (result === 'NOT_FOUND') throw new NotFoundException();
|
||||
if (result === 'TAKEN') throw new ConflictException('This slug is already used');
|
||||
}
|
||||
|
||||
/** @returns the current slug of the entity that `slug` used to name, null when it is not a former slug. */
|
||||
async findRedirect(type: SlugEntityType, slug: string): Promise<string | null> {
|
||||
return this.slugDatabaseRepository.findRedirect(type, slug);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user