feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection
This commit is contained in:
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user