From dac8a1b7a195ddbce45190771c84e8e8e211ef68 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sat, 14 Nov 2026 12:48:31 +0100 Subject: [PATCH] fix(seo): keep whole words when truncating slugs, detect unique violations as Prisma errors --- src/services/slug/slug.service.spec.ts | 16 ++++++++++++++-- src/services/slug/slug.service.ts | 7 +++++-- src/utils/slug.spec.ts | 6 ++++++ src/utils/slug.ts | 8 ++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/services/slug/slug.service.spec.ts b/src/services/slug/slug.service.spec.ts index 96e9160..46091f2 100644 --- a/src/services/slug/slug.service.spec.ts +++ b/src/services/slug/slug.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common'; import { SlugService } from './slug.service'; -import { SlugEntityType } from '../../../prisma/generated/client'; +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 }); @@ -93,10 +93,22 @@ describe('SlugService', () => { 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' })); + 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'); diff --git a/src/services/slug/slug.service.ts b/src/services/slug/slug.service.ts index 35b710e..4e11c95 100644 --- a/src/services/slug/slug.service.ts +++ b/src/services/slug/slug.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { SlugEntityType } from '../../../prisma/generated/client'; +import { Prisma, 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'; @@ -17,6 +17,9 @@ const FALLBACK_PREFIX: Record = { * 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. + * A rebuild only gives back the stored slugs as long as their history did not change them: a slug replaced by an admin, + * an entity renamed in Hiboutik after its slug was generated, or a homonym deleted since (the younger one, suffixed, + * would now get the plain slug) all give a different slug on a rebuild, and the redirects are lost with the database. * An admin can replace a slug ({@link changeSlug}): the former one is kept as a redirect. */ @Injectable() @@ -83,7 +86,7 @@ export class SlugService { 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'; + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') result = 'TAKEN'; else throw error; } if (result === 'NOT_FOUND') throw new NotFoundException(); diff --git a/src/utils/slug.spec.ts b/src/utils/slug.spec.ts index 61ce258..b30bd5e 100644 --- a/src/utils/slug.spec.ts +++ b/src/utils/slug.spec.ts @@ -21,6 +21,12 @@ describe('slugify', () => { expect(slug.length).toBeLessThanOrEqual(MAX_SLUG_LENGTH); expect(slug).toMatch(SLUG_PATTERN); }); + + it('keeps the last word when the cut falls right before a hyphen', () => { + expect(slugify('aa bbb ccc', 6)).toBe('aa-bbb'); + expect(slugify('aa bbb ccc', 7)).toBe('aa-bbb'); + expect(slugify('aa bbb ccc', 5)).toBe('aa'); + }); }); describe('withSlugSuffix', () => { diff --git a/src/utils/slug.ts b/src/utils/slug.ts index b87cafb..2a02331 100644 --- a/src/utils/slug.ts +++ b/src/utils/slug.ts @@ -15,7 +15,7 @@ const SPECIAL_LETTERS: Record = { æ: 'ae', œ: 'oe', ø: 'o', export function slugify(name: string, maxLength = MAX_SLUG_LENGTH): string { const slug = name .normalize('NFKD') - .replace(/[̀-ͯ]/g, '') + .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[æœøßđłþ]/g, (letter) => SPECIAL_LETTERS[letter]) .replace(/&/g, ' and ') @@ -24,10 +24,14 @@ export function slugify(name: string, maxLength = MAX_SLUG_LENGTH): string { return truncateSlug(slug, maxLength); } -/** Cuts a slug to `maxLength`, at the last hyphen when there is one, never leaving a trailing hyphen. */ +/** + * Cuts a slug to `maxLength` at a word boundary: keeps the cut as is when it ends on a whole word, else cuts at its last + * hyphen when there is one. Never leaves a trailing hyphen. + */ function truncateSlug(slug: string, maxLength: number): string { if (slug.length <= maxLength) return slug; const cut = slug.slice(0, maxLength); + if (slug[maxLength] === '-') return cut; const lastHyphen = cut.lastIndexOf('-'); return (lastHyphen > 0 ? cut.slice(0, lastHyphen) : cut).replace(/-+$/, ''); }