feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection

This commit is contained in:
MastersGame
2026-11-14 11:08:51 +01:00
parent d07c4df52d
commit 657e8a6b30
26 changed files with 750 additions and 12 deletions

View File

@@ -3,6 +3,8 @@ import { BrandHiboutikRepository } from '../../hiboutik/brand.hiboutik.repositor
import { BrandDatabaseRepository } from '../../database/brand.database.repository';
import { BrandConverter } from '../../models/converter/brand.converter';
import { forEachSafely } from '../../utils/sync';
import { SlugService } from '../slug/slug.service';
import { SlugEntityType } from '../../../prisma/generated/client';
@Injectable()
export class BrandHiboutikService {
@@ -12,9 +14,10 @@ export class BrandHiboutikService {
private readonly brandHiboutikRepository: BrandHiboutikRepository,
private readonly brandDatabaseRepository: BrandDatabaseRepository,
private readonly brandConverter: BrandConverter,
private readonly slugService: SlugService,
) {}
/** Mirrors Hiboutik brands: upserts each of them, then deletes the brands missing from Hiboutik. */
/** Mirrors Hiboutik brands: upserts each of them, deletes the brands missing from Hiboutik, then gives a slug to the new ones. */
async syncBrands() {
const hiboutikBrands = await this.brandHiboutikRepository.getBrands();
// One upsert per brand: an invalid brand must not prevent the others from being saved
@@ -27,6 +30,7 @@ export class BrandHiboutikService {
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => dto.brand_id));
await this.slugService.assignMissingSlugs(SlugEntityType.BRAND);
this.logger.log(
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);

View File

@@ -16,6 +16,7 @@ const hiboutikCategory = (id: number, parentId: number, overrides: Partial<Categ
describe('CategoryHiboutikService', () => {
const categoryHiboutikRepository = { getCategories: jest.fn() };
const categoryDatabaseRepository = { upsert: jest.fn(), setParent: jest.fn(), deleteMissing: jest.fn() };
const slugService = { assignMissingSlugs: jest.fn() };
let service: CategoryHiboutikService;
let calls: string[];
@@ -41,6 +42,7 @@ describe('CategoryHiboutikService', () => {
categoryHiboutikRepository as never,
categoryDatabaseRepository as never,
new CategoryConverter(),
slugService as never,
);
});
@@ -93,4 +95,16 @@ describe('CategoryHiboutikService', () => {
expect(categoryDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
});
it('generates the slugs of new categories once they are saved', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0)]);
slugService.assignMissingSlugs.mockImplementation(() => {
calls.push('slugs');
return Promise.resolve();
});
await service.syncCategories();
expect(slugService.assignMissingSlugs).toHaveBeenCalledWith('CATEGORY');
expect(calls[calls.length - 1]).toBe('slugs');
});
});

View File

@@ -3,6 +3,8 @@ import { CategoryHiboutikRepository } from '../../hiboutik/category.hiboutik.rep
import { CategoryDatabaseRepository } from '../../database/category.database.repository';
import { CategoryConverter } from '../../models/converter/category.converter';
import { forEachSafely } from '../../utils/sync';
import { SlugService } from '../slug/slug.service';
import { SlugEntityType } from '../../../prisma/generated/client';
@Injectable()
export class CategoryHiboutikService {
@@ -12,11 +14,12 @@ export class CategoryHiboutikService {
private readonly categoryHiboutikRepository: CategoryHiboutikRepository,
private readonly categoryDatabaseRepository: CategoryDatabaseRepository,
private readonly categoryConverter: CategoryConverter,
private readonly slugService: SlugService,
) {}
/**
* Mirrors Hiboutik categories and their tree: upserts each of them, links them to their parent,
* then deletes the categories missing from Hiboutik.
* deletes the categories missing from Hiboutik, then gives a slug to the new ones.
*/
async syncCategories() {
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories();
@@ -39,6 +42,7 @@ export class CategoryHiboutikService {
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => dto.category_id));
await this.slugService.assignMissingSlugs(SlugEntityType.CATEGORY);
this.logger.log(
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,

View File

@@ -38,6 +38,7 @@ describe('ProductHiboutikService', () => {
// The transaction client handed to the repositories inside $transaction
const tx = { transaction: true };
const prisma = { $transaction: jest.fn() };
const slugService = { assignMissingSlugs: jest.fn() };
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
@@ -76,6 +77,7 @@ describe('ProductHiboutikService', () => {
new ProductConverter(),
stockHiboutikRepository as never,
prisma as never,
slugService as never,
);
});
@@ -194,4 +196,16 @@ describe('ProductHiboutikService', () => {
await expect(prisma.$transaction.mock.results[0].value).rejects.toThrow('boom');
await expect(prisma.$transaction.mock.results[1].value).resolves.toBeUndefined();
});
it('generates the slugs of new products once they are saved', async () => {
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
slugService.assignMissingSlugs.mockImplementation(() => {
calls.push('slugs');
return Promise.resolve();
});
await service.syncProducts();
expect(slugService.assignMissingSlugs).toHaveBeenCalledWith('PRODUCT');
expect(calls[calls.length - 1]).toBe('slugs');
});
});

View File

@@ -6,11 +6,12 @@ import { ProductConverter } from '../../models/converter/product.converter';
import { StockHiboutikRepository } from '../../hiboutik/stock.hiboutik.repository';
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
import { Product } from '../../../prisma/generated/client';
import { Product, SlugEntityType } from '../../../prisma/generated/client';
import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion';
import { parseOptionalHiboutikPrice } from '../../utils/price';
import { PrismaService } from '../../../prisma/prisma.service';
import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client';
import { SlugService } from '../slug/slug.service';
/** Version id used for products that have no sizes in Hiboutik. */
const NO_SIZE_ID = 0;
@@ -26,9 +27,10 @@ export class ProductHiboutikService {
private readonly productConverter: ProductConverter,
private readonly stockHiboutikRepository: StockHiboutikRepository,
private readonly prisma: PrismaService,
private readonly slugService: SlugService,
) {}
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted, new ones get a slug. */
async syncProducts() {
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
this.productHiboutikRepository.getProducts(),
@@ -59,6 +61,7 @@ export class ProductHiboutikService {
}
}
await this.slugService.assignMissingSlugs(SlugEntityType.PRODUCT);
this.logger.log(
`Products sync ended: ${productsHiboutik.length - nbFailed}/${productsHiboutik.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);