dev #55

Merged
JoeKung merged 4 commits from dev into main 2026-03-25 11:57:50 +01:00
9 changed files with 165 additions and 33 deletions
Showing only changes of commit b30bfc9293 - Show all commits

View File

@@ -62,6 +62,12 @@ public class PublicShopController {
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")
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);

View File

@@ -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) {
CategoryContext categoryContext = loadCategoryContext(null);
PublicProductContext productContext = loadPublicProductContext(categoryContext, null);
@@ -231,11 +253,19 @@ public class PublicShopCatalogService {
(left, right) -> left,
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(
entries,
entriesBySlug,
entriesByPublicPath,
entriesByIdPrefix,
productMediaBySlug,
variantColorHexByMaterialAndColor
);
@@ -566,6 +596,15 @@ public class PublicShopCatalogService {
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) {
String raw = String.valueOf(value == null ? "" : value).trim();
if (raw.isEmpty()) {
@@ -662,6 +701,7 @@ public class PublicShopCatalogService {
List<ProductEntry> entries,
Map<String, ProductEntry> entriesBySlug,
Map<String, ProductEntry> entriesByPublicPath,
Map<String, ProductEntry> entriesByIdPrefix,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor
) {

View File

@@ -108,6 +108,21 @@ class PublicShopCatalogServiceTest {
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
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
ShopCategory category = buildCategory();

View File

@@ -92,10 +92,10 @@ describe('serverOriginInterceptor', () => {
it('uses the internal SSR API origin for public shop discovery calls', () => {
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(
'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(
'Basic dGVzdDp0ZXN0',
@@ -133,10 +133,10 @@ describe('serverOriginInterceptor', () => {
http = TestBed.inject(HttpClient);
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(
'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('cookie')).toBe('session=abc123');
@@ -184,10 +184,10 @@ describe('serverOriginInterceptor', () => {
http = TestBed.inject(HttpClient);
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(
'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('cookie')).toBe('session=abc123');

View File

@@ -19,6 +19,7 @@ const FORWARDED_REQUEST_HEADERS = [
const SHOP_DISCOVERY_API_PATTERNS = [
/^\/api\/shop\/categories(?:\/[^/?#]+)?$/i,
/^\/api\/shop\/products$/i,
/^\/api\/shop\/products\/by-id-prefix\/[^/?#]+$/i,
/^\/api\/shop\/products\/by-path\/[^/?#]+$/i,
/^\/api\/shop\/products\/[^/?#]+$/i,
] as const;

View File

@@ -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 seoService = jasmine.createSpyObj<SeoService>('SeoService', [
'applyResolvedSeo',
@@ -93,10 +99,12 @@ describe('ProductDetailComponent', () => {
return translations[key] ?? key;
});
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>(
options?.currentLang ?? 'de',
);
const languageService = {
currentLang,
selectedLang: () => currentLang(),
selectedLang: () => options?.selectedLang ?? currentLang(),
setLocalizedRouteOverrides: jasmine.createSpy(
'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', () => {
const { component, seoService } = createComponent();

View File

@@ -605,7 +605,7 @@ export class ProductDetailComponent {
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots =
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
const lang = this.languageService.selectedLang();
const lang = this.languageService.currentLang();
const canonicalPath =
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
@@ -857,7 +857,7 @@ export class ProductDetailComponent {
}
const currentTree = this.router.parseUrl(this.router.url);
const lang = this.languageService.selectedLang();
const lang = this.languageService.currentLang();
const targetPath =
product.localizedPaths?.[lang] ??
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;

View File

@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
@@ -13,6 +14,11 @@ import { LanguageService } from '../../../core/services/language.service';
describe('ShopService', () => {
let service: ShopService;
let httpMock: HttpTestingController;
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
const languageService = {
currentLang,
selectedLang: jasmine.createSpy('selectedLang').and.returnValue('it'),
};
const buildCart = (): ShopCartResponse => ({
session: {
@@ -131,13 +137,14 @@ describe('ShopService', () => {
ShopService,
{
provide: LanguageService,
useValue: {
selectedLang: () => 'it',
},
useValue: languageService,
},
],
});
currentLang.set('it');
languageService.selectedLang.and.returnValue('it');
service = TestBed.inject(ShopService);
httpMock = TestBed.inject(HttpTestingController);
});
@@ -196,7 +203,7 @@ describe('ShopService', () => {
return (
request.method === 'GET' &&
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'
);
});
@@ -206,47 +213,74 @@ describe('ShopService', () => {
expect(response?.name).toBe('Supporto cavo scrivania');
});
it('rejects product paths whose slug tail does not match the canonical path', () => {
let errorResponse: { status?: number } | undefined;
it('resolves products from the stable uuid prefix even if the slug tail is stale', () => {
let response: ShopProductDetail | undefined;
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
next: () => fail('Expected canonical path mismatch to return 404'),
error: (error) => {
errorResponse = error;
next: (product) => {
response = product;
},
error: () => fail('Expected stale slug tails to resolve from the uuid prefix'),
});
const request = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
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.flush('Not found', { status: 404, statusText: 'Not Found' });
expect(errorResponse?.status).toBe(404);
request.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
});
it('rejects bare uuid product paths without the localized slug tail', () => {
let errorResponse: { status?: number } | undefined;
it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => {
let response: ShopProductDetail | undefined;
service.getProductByPublicPath('12345678').subscribe({
next: () => fail('Expected bare uuid path to return 404'),
error: (error) => {
errorResponse = error;
next: (product) => {
response = product;
},
error: () => fail('Expected bare uuid path to resolve from the uuid prefix'),
});
const request = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
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.flush('Not found', { status: 404, statusText: 'Not Found' });
expect(errorResponse?.status).toBe(404);
request.flush(buildProduct());
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');
});
});

View File

@@ -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>(
`${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
endpoint,
{
params: this.buildLangParams(),
},
@@ -304,6 +309,11 @@ export class ShopService {
.toLowerCase();
}
private extractProductIdPrefix(value: string): string | null {
const match = value.match(/^([0-9a-f]{8})(?:-|$)/);
return match?.[1] ?? null;
}
loadCart(): Observable<ShopCartResponse> {
this.cartLoading.set(true);
return this.http
@@ -455,7 +465,10 @@ export class ShopService {
}
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 {