fix(front-end): improvements in load products by uuid truncated
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m37s
Build and Deploy / build-and-push (push) Successful in 1m57s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / security-sast (pull_request) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m15s
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m37s
Build and Deploy / build-and-push (push) Successful in 1m57s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / security-sast (pull_request) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m15s
This commit is contained in:
@@ -62,6 +62,12 @@ public class PublicShopController {
|
|||||||
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/products/by-id-prefix/{idPrefix}")
|
||||||
|
public ResponseEntity<ShopProductDetailDto> getProductByIdPrefix(@PathVariable String idPrefix,
|
||||||
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicShopCatalogService.getProductByIdPrefix(idPrefix, lang));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/products/{slug}/model")
|
@GetMapping("/products/{slug}/model")
|
||||||
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
||||||
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
||||||
|
|||||||
@@ -163,6 +163,28 @@ public class PublicShopCatalogService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ShopProductDetailDto getProductByIdPrefix(String idPrefix, String language) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String normalizedIdPrefix = normalizeProductIdPrefix(idPrefix);
|
||||||
|
if (normalizedIdPrefix == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
|
productContext.entriesByIdPrefix().get(normalizedIdPrefix),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
|
||||||
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public ProductModelDownload getProductModelDownload(String slug) {
|
public ProductModelDownload getProductModelDownload(String slug) {
|
||||||
CategoryContext categoryContext = loadCategoryContext(null);
|
CategoryContext categoryContext = loadCategoryContext(null);
|
||||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, null);
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, null);
|
||||||
@@ -231,11 +253,19 @@ public class PublicShopCatalogService {
|
|||||||
(left, right) -> left,
|
(left, right) -> left,
|
||||||
LinkedHashMap::new
|
LinkedHashMap::new
|
||||||
));
|
));
|
||||||
|
Map<String, ProductEntry> entriesByIdPrefix = entries.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
entry -> normalizeProductIdPrefix(ShopPublicPathSupport.productIdPrefix(entry.product().getId())),
|
||||||
|
entry -> entry,
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
|
||||||
return new PublicProductContext(
|
return new PublicProductContext(
|
||||||
entries,
|
entries,
|
||||||
entriesBySlug,
|
entriesBySlug,
|
||||||
entriesByPublicPath,
|
entriesByPublicPath,
|
||||||
|
entriesByIdPrefix,
|
||||||
productMediaBySlug,
|
productMediaBySlug,
|
||||||
variantColorHexByMaterialAndColor
|
variantColorHexByMaterialAndColor
|
||||||
);
|
);
|
||||||
@@ -566,6 +596,15 @@ public class PublicShopCatalogService {
|
|||||||
return normalized.toLowerCase(Locale.ROOT);
|
return normalized.toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeProductIdPrefix(String idPrefix) {
|
||||||
|
String normalized = trimToNull(idPrefix);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
normalized = normalized.toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.matches("^[0-9a-f]{8}$") ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
private String trimToNull(String value) {
|
private String trimToNull(String value) {
|
||||||
String raw = String.valueOf(value == null ? "" : value).trim();
|
String raw = String.valueOf(value == null ? "" : value).trim();
|
||||||
if (raw.isEmpty()) {
|
if (raw.isEmpty()) {
|
||||||
@@ -662,6 +701,7 @@ public class PublicShopCatalogService {
|
|||||||
List<ProductEntry> entries,
|
List<ProductEntry> entries,
|
||||||
Map<String, ProductEntry> entriesBySlug,
|
Map<String, ProductEntry> entriesBySlug,
|
||||||
Map<String, ProductEntry> entriesByPublicPath,
|
Map<String, ProductEntry> entriesByPublicPath,
|
||||||
|
Map<String, ProductEntry> entriesByIdPrefix,
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||||
Map<String, String> variantColorHexByMaterialAndColor
|
Map<String, String> variantColorHexByMaterialAndColor
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -108,6 +108,21 @@ class PublicShopCatalogServiceTest {
|
|||||||
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByIdPrefix_shouldResolveLocalizedProduct() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProductByIdPrefix("12345678", "de");
|
||||||
|
|
||||||
|
assertEquals("bike-wall-hanger", response.slug());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/de/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("de"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
||||||
ShopCategory category = buildCategory();
|
ShopCategory category = buildCategory();
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ describe('serverOriginInterceptor', () => {
|
|||||||
it('uses the internal SSR API origin for public shop discovery calls', () => {
|
it('uses the internal SSR API origin for public shop discovery calls', () => {
|
||||||
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
http.get('/api/shop/products/by-path/example?lang=de').subscribe();
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
const request = httpMock.expectOne(
|
const request = httpMock.expectOne(
|
||||||
'http://backend:8000/api/shop/products/by-path/example?lang=de',
|
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
);
|
);
|
||||||
expect(request.request.headers.get('authorization')).toBe(
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
'Basic dGVzdDp0ZXN0',
|
'Basic dGVzdDp0ZXN0',
|
||||||
@@ -133,10 +133,10 @@ describe('serverOriginInterceptor', () => {
|
|||||||
http = TestBed.inject(HttpClient);
|
http = TestBed.inject(HttpClient);
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
http.get('/api/shop/products/by-path/example?lang=de').subscribe();
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
const request = httpMock.expectOne(
|
const request = httpMock.expectOne(
|
||||||
'http://backend:8000/api/shop/products/by-path/example?lang=de',
|
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
);
|
);
|
||||||
expect(request.request.headers.get('authorization')).toBeNull();
|
expect(request.request.headers.get('authorization')).toBeNull();
|
||||||
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
@@ -184,10 +184,10 @@ describe('serverOriginInterceptor', () => {
|
|||||||
http = TestBed.inject(HttpClient);
|
http = TestBed.inject(HttpClient);
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
http.get('/api/shop/products/by-path/example?lang=de').subscribe();
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
const request = httpMock.expectOne(
|
const request = httpMock.expectOne(
|
||||||
'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de',
|
'https://dev.3d-fab.ch/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
);
|
);
|
||||||
expect(request.request.headers.get('authorization')).toBeNull();
|
expect(request.request.headers.get('authorization')).toBeNull();
|
||||||
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const FORWARDED_REQUEST_HEADERS = [
|
|||||||
const SHOP_DISCOVERY_API_PATTERNS = [
|
const SHOP_DISCOVERY_API_PATTERNS = [
|
||||||
/^\/api\/shop\/categories(?:\/[^/?#]+)?$/i,
|
/^\/api\/shop\/categories(?:\/[^/?#]+)?$/i,
|
||||||
/^\/api\/shop\/products$/i,
|
/^\/api\/shop\/products$/i,
|
||||||
|
/^\/api\/shop\/products\/by-id-prefix\/[^/?#]+$/i,
|
||||||
/^\/api\/shop\/products\/by-path\/[^/?#]+$/i,
|
/^\/api\/shop\/products\/by-path\/[^/?#]+$/i,
|
||||||
/^\/api\/shop\/products\/[^/?#]+$/i,
|
/^\/api\/shop\/products\/[^/?#]+$/i,
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -71,7 +71,13 @@ describe('ProductDetailComponent', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createComponent(routerUrl = '/de/shop/p/91823f84-bike-wall-hanger') {
|
function createComponent(
|
||||||
|
routerUrl = '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
options?: {
|
||||||
|
currentLang?: 'it' | 'en' | 'de' | 'fr';
|
||||||
|
selectedLang?: 'it' | 'en' | 'de' | 'fr';
|
||||||
|
},
|
||||||
|
) {
|
||||||
const responseInit: { status?: number } = {};
|
const responseInit: { status?: number } = {};
|
||||||
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||||
'applyResolvedSeo',
|
'applyResolvedSeo',
|
||||||
@@ -93,10 +99,12 @@ describe('ProductDetailComponent', () => {
|
|||||||
return translations[key] ?? key;
|
return translations[key] ?? key;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>(
|
||||||
|
options?.currentLang ?? 'de',
|
||||||
|
);
|
||||||
const languageService = {
|
const languageService = {
|
||||||
currentLang,
|
currentLang,
|
||||||
selectedLang: () => currentLang(),
|
selectedLang: () => options?.selectedLang ?? currentLang(),
|
||||||
setLocalizedRouteOverrides: jasmine.createSpy(
|
setLocalizedRouteOverrides: jasmine.createSpy(
|
||||||
'setLocalizedRouteOverrides',
|
'setLocalizedRouteOverrides',
|
||||||
),
|
),
|
||||||
@@ -193,6 +201,21 @@ describe('ProductDetailComponent', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the route language for canonical SEO even if the selected translation language lags', () => {
|
||||||
|
const { component, seoService } = createComponent(undefined, {
|
||||||
|
currentLang: 'de',
|
||||||
|
selectedLang: 'en',
|
||||||
|
});
|
||||||
|
|
||||||
|
(component as any).applySeo(buildProduct());
|
||||||
|
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies noindex for products explicitly marked as non-indexable', () => {
|
it('applies noindex for products explicitly marked as non-indexable', () => {
|
||||||
const { component, seoService } = createComponent();
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ export class ProductDetailComponent {
|
|||||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
const robots =
|
const robots =
|
||||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||||
const lang = this.languageService.selectedLang();
|
const lang = this.languageService.currentLang();
|
||||||
const canonicalPath =
|
const canonicalPath =
|
||||||
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
|
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
|
||||||
|
|
||||||
@@ -857,7 +857,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTree = this.router.parseUrl(this.router.url);
|
const currentTree = this.router.parseUrl(this.router.url);
|
||||||
const lang = this.languageService.selectedLang();
|
const lang = this.languageService.currentLang();
|
||||||
const targetPath =
|
const targetPath =
|
||||||
product.localizedPaths?.[lang] ??
|
product.localizedPaths?.[lang] ??
|
||||||
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
|
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import {
|
import {
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
@@ -13,6 +14,11 @@ import { LanguageService } from '../../../core/services/language.service';
|
|||||||
describe('ShopService', () => {
|
describe('ShopService', () => {
|
||||||
let service: ShopService;
|
let service: ShopService;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: jasmine.createSpy('selectedLang').and.returnValue('it'),
|
||||||
|
};
|
||||||
|
|
||||||
const buildCart = (): ShopCartResponse => ({
|
const buildCart = (): ShopCartResponse => ({
|
||||||
session: {
|
session: {
|
||||||
@@ -131,13 +137,14 @@ describe('ShopService', () => {
|
|||||||
ShopService,
|
ShopService,
|
||||||
{
|
{
|
||||||
provide: LanguageService,
|
provide: LanguageService,
|
||||||
useValue: {
|
useValue: languageService,
|
||||||
selectedLang: () => 'it',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
currentLang.set('it');
|
||||||
|
languageService.selectedLang.and.returnValue('it');
|
||||||
|
|
||||||
service = TestBed.inject(ShopService);
|
service = TestBed.inject(ShopService);
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
@@ -196,7 +203,7 @@ describe('ShopService', () => {
|
|||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/by-path/12345678-supporto-cavo-scrivania' &&
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -206,47 +213,74 @@ describe('ShopService', () => {
|
|||||||
expect(response?.name).toBe('Supporto cavo scrivania');
|
expect(response?.name).toBe('Supporto cavo scrivania');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects product paths whose slug tail does not match the canonical path', () => {
|
it('resolves products from the stable uuid prefix even if the slug tail is stale', () => {
|
||||||
let errorResponse: { status?: number } | undefined;
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
|
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
|
||||||
next: () => fail('Expected canonical path mismatch to return 404'),
|
next: (product) => {
|
||||||
error: (error) => {
|
response = product;
|
||||||
errorResponse = error;
|
|
||||||
},
|
},
|
||||||
|
error: () => fail('Expected stale slug tails to resolve from the uuid prefix'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const request = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/by-path/12345678-qualunque-nome' &&
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
request.flush(buildProduct());
|
||||||
expect(errorResponse?.status).toBe(404);
|
|
||||||
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects bare uuid product paths without the localized slug tail', () => {
|
it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => {
|
||||||
let errorResponse: { status?: number } | undefined;
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
service.getProductByPublicPath('12345678').subscribe({
|
service.getProductByPublicPath('12345678').subscribe({
|
||||||
next: () => fail('Expected bare uuid path to return 404'),
|
next: (product) => {
|
||||||
error: (error) => {
|
response = product;
|
||||||
errorResponse = error;
|
|
||||||
},
|
},
|
||||||
|
error: () => fail('Expected bare uuid path to resolve from the uuid prefix'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const request = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/by-path/12345678' &&
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
request.flush(buildProduct());
|
||||||
expect(errorResponse?.status).toBe(404);
|
|
||||||
|
expect(response?.publicPath).toBe('12345678-supporto-cavo-scrivania');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the route language for public shop lookups when translate.currentLang lags behind', () => {
|
||||||
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
|
currentLang.set('de');
|
||||||
|
languageService.selectedLang.and.returnValue('en');
|
||||||
|
|
||||||
|
service
|
||||||
|
.getProductByPublicPath('12345678-schreibtisch-kabelhalter')
|
||||||
|
.subscribe((product) => {
|
||||||
|
response = product;
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne((request) => {
|
||||||
|
return (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
request.url ===
|
||||||
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
|
request.params.get('lang') === 'de'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
request.flush(buildProduct());
|
||||||
|
|
||||||
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -290,8 +290,13 @@ export class ShopService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const productIdPrefix = this.extractProductIdPrefix(normalizedPath);
|
||||||
|
const endpoint = productIdPrefix
|
||||||
|
? `${this.apiUrl}/products/by-id-prefix/${encodeURIComponent(productIdPrefix)}`
|
||||||
|
: `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`;
|
||||||
|
|
||||||
return this.http.get<ShopProductDetail>(
|
return this.http.get<ShopProductDetail>(
|
||||||
`${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
|
endpoint,
|
||||||
{
|
{
|
||||||
params: this.buildLangParams(),
|
params: this.buildLangParams(),
|
||||||
},
|
},
|
||||||
@@ -304,6 +309,11 @@ export class ShopService {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractProductIdPrefix(value: string): string | null {
|
||||||
|
const match = value.match(/^([0-9a-f]{8})(?:-|$)/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
loadCart(): Observable<ShopCartResponse> {
|
loadCart(): Observable<ShopCartResponse> {
|
||||||
this.cartLoading.set(true);
|
this.cartLoading.set(true);
|
||||||
return this.http
|
return this.http
|
||||||
@@ -455,7 +465,10 @@ export class ShopService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildLangParams(): HttpParams {
|
private buildLangParams(): HttpParams {
|
||||||
return new HttpParams().set('lang', this.languageService.selectedLang());
|
// Public shop URLs are localized. During direct loads the translation
|
||||||
|
// service can still momentarily reflect the browser language, while the
|
||||||
|
// route language has already been resolved from the URL.
|
||||||
|
return new HttpParams().set('lang', this.languageService.currentLang());
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCart(cart: ShopCartResponse): void {
|
private setCart(cart: ShopCartResponse): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user