From d70423fcc0d0bcf38fc1294ad97f3f3aa7e8117c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 24 Mar 2026 16:20:40 +0100 Subject: [PATCH 1/3] fix(front-end): improvements in ssr --- docker-compose.deploy.yml | 2 + .../server-origin.interceptor.spec.ts | 119 ++++++++++++++++++ .../interceptors/server-origin.interceptor.ts | 103 ++++++++++++++- 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 1086e1c..5198faf 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -62,6 +62,8 @@ services: container_name: print-calculator-frontend-${ENV} ports: - "${FRONTEND_PORT}:80" + environment: + - SSR_INTERNAL_API_ORIGIN=http://backend:8000 depends_on: - backend restart: always diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts index 403fc8d..f165c08 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts @@ -8,11 +8,19 @@ import { REQUEST } from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { serverOriginInterceptor } from './server-origin.interceptor'; +type TestGlobal = typeof globalThis & { + __SSR_INTERNAL_API_ORIGIN__?: string; +}; + describe('serverOriginInterceptor', () => { let http: HttpClient; let httpMock: HttpTestingController; + const testGlobal = globalThis as TestGlobal; + const originalInternalApiOrigin = testGlobal.__SSR_INTERNAL_API_ORIGIN__; beforeEach(() => { + delete testGlobal.__SSR_INTERNAL_API_ORIGIN__; + TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptors([serverOriginInterceptor])), @@ -21,6 +29,7 @@ describe('serverOriginInterceptor', () => { provide: REQUEST, useValue: { protocol: 'https', + url: '/de/shop/p/91823f84-bike-wall-hanger', headers: { host: 'dev.3d-fab.ch', authorization: 'Basic dGVzdDp0ZXN0', @@ -38,6 +47,11 @@ describe('serverOriginInterceptor', () => { afterEach(() => { httpMock.verify(); + if (originalInternalApiOrigin) { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = originalInternalApiOrigin; + return; + } + delete testGlobal.__SSR_INTERNAL_API_ORIGIN__; }); it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => { @@ -74,4 +88,109 @@ describe('serverOriginInterceptor', () => { expect(request.request.headers.get('cookie')).toBe('session=abc123'); request.flush({}); }); + + 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(); + + const request = httpMock.expectOne( + 'http://backend:8000/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBe( + 'Basic dGVzdDp0ZXN0', + ); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + expect(request.request.headers.get('accept-language')).toBe( + 'de-CH,de;q=0.9,en;q=0.8', + ); + request.flush({}); + }); + + it('bypasses the public origin even when the proxy strips authorization on shop SSR requests', () => { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverOriginInterceptor])), + provideHttpClientTesting(), + { + provide: REQUEST, + useValue: { + protocol: 'https', + url: '/de/shop/p/91823f84-bike-wall-hanger', + headers: { + host: 'dev.3d-fab.ch', + cookie: 'session=abc123', + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8', + }, + }, + }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + + http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + + const request = httpMock.expectOne( + 'http://backend:8000/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBeNull(); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + expect(request.request.headers.get('accept-language')).toBe( + 'de-CH,de;q=0.9,en;q=0.8', + ); + request.flush({}); + }); + + it('keeps transactional shop API calls on the public origin', () => { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; + + http.get('/api/shop/cart').subscribe(); + + const request = httpMock.expectOne('https://dev.3d-fab.ch/api/shop/cart'); + expect(request.request.headers.get('authorization')).toBe( + 'Basic dGVzdDp0ZXN0', + ); + request.flush({}); + }); + + it('keeps non-shop pages on the public origin even for public shop APIs', () => { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverOriginInterceptor])), + provideHttpClientTesting(), + { + provide: REQUEST, + useValue: { + protocol: 'https', + url: '/de/checkout?session=abc', + headers: { + host: 'dev.3d-fab.ch', + cookie: 'session=abc123', + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8', + }, + }, + }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + + http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + + const request = httpMock.expectOne( + 'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBeNull(); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + request.flush({}); + }); }); diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index 836debb..59b7ff7 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -5,12 +5,26 @@ import { resolveRequestOrigin, } from '../../../core/request-origin'; +type ServerRequestLike = RequestLike & { + originalUrl?: string; + url?: string; +}; + const FORWARDED_REQUEST_HEADERS = [ 'authorization', 'cookie', 'accept-language', ] as const; +const SHOP_DISCOVERY_API_PATTERNS = [ + /^\/api\/shop\/categories(?:\/[^/?#]+)?$/i, + /^\/api\/shop\/products$/i, + /^\/api\/shop\/products\/by-path\/[^/?#]+$/i, + /^\/api\/shop\/products\/[^/?#]+$/i, +] as const; + +const SHOP_PAGE_PATH_PATTERN = /^\/(?:it|en|de|fr)\/shop(?:\/.*)?$/i; + function isAbsoluteUrl(url: string): boolean { return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); } @@ -20,6 +34,14 @@ function normalizeRelativePath(url: string): string { return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; } +function stripQueryAndHash(url: string): string { + return String(url ?? '').split(/[?#]/, 1)[0] || '/'; +} + +function normalizeOrigin(origin: string): string { + return origin.replace(/\/+$/, ''); +} + function readRequestHeader( request: RequestLike | null, name: (typeof FORWARDED_REQUEST_HEADERS)[number], @@ -34,18 +56,93 @@ function readRequestHeader( return typeof headerValue === 'string' ? headerValue : null; } +function readRequestPath(request: ServerRequestLike | null): string | null { + const rawPath = + (typeof request?.originalUrl === 'string' && request.originalUrl) || + (typeof request?.url === 'string' && request.url) || + null; + if (!rawPath) { + return null; + } + + if (isAbsoluteUrl(rawPath)) { + try { + return stripQueryAndHash(new URL(rawPath).pathname || '/'); + } catch { + return null; + } + } + + return stripQueryAndHash(rawPath.startsWith('/') ? rawPath : `/${rawPath}`); +} + +function isPublicShopPageRequest(request: ServerRequestLike | null): boolean { + const requestPath = readRequestPath(request); + return !!requestPath && SHOP_PAGE_PATH_PATTERN.test(requestPath); +} + +function isPublicShopDiscoveryApi(url: string): boolean { + const normalizedPath = stripQueryAndHash(normalizeRelativePath(url)); + return SHOP_DISCOVERY_API_PATTERNS.some((pattern) => + pattern.test(normalizedPath), + ); +} + +function readInternalApiOrigin(): string | null { + const globalObject = globalThis as { + __SSR_INTERNAL_API_ORIGIN__?: string; + process?: { + env?: Record; + }; + }; + const explicitOverride = + typeof globalObject.__SSR_INTERNAL_API_ORIGIN__ === 'string' + ? globalObject.__SSR_INTERNAL_API_ORIGIN__ + : null; + const env = ( + globalObject as { + process?: { + env?: Record; + }; + } + ).process?.env; + const rawValue = explicitOverride ?? env?.['SSR_INTERNAL_API_ORIGIN']; + if (typeof rawValue !== 'string') { + return null; + } + + const normalized = rawValue.trim(); + return normalized ? normalizeOrigin(normalized) : null; +} + +function resolveApiOrigin( + request: ServerRequestLike | null, + relativeUrl: string, +): string | null { + const internalOrigin = readInternalApiOrigin(); + if ( + internalOrigin && + isPublicShopPageRequest(request) && + isPublicShopDiscoveryApi(relativeUrl) + ) { + return internalOrigin; + } + + return resolveRequestOrigin(request); +} + export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { if (isAbsoluteUrl(req.url)) { return next(req); } - const request = inject(REQUEST, { optional: true }) as RequestLike | null; - const origin = resolveRequestOrigin(request); + const request = inject(REQUEST, { optional: true }) as ServerRequestLike | null; + const origin = resolveApiOrigin(request, req.url); if (!origin) { return next(req); } - const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`; + const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`; const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce< Record >((headers, name) => { From b30bfc929369d872ba15ff7ad00736780f5d8045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 25 Mar 2026 11:27:43 +0100 Subject: [PATCH 2/3] fix(front-end): improvements in load products by uuid truncated --- .../controller/PublicShopController.java | 6 ++ .../shop/PublicShopCatalogService.java | 40 ++++++++++ .../shop/PublicShopCatalogServiceTest.java | 15 ++++ .../server-origin.interceptor.spec.ts | 12 +-- .../interceptors/server-origin.interceptor.ts | 1 + .../shop/product-detail.component.spec.ts | 29 +++++++- .../features/shop/product-detail.component.ts | 4 +- .../shop/services/shop.service.spec.ts | 74 ++++++++++++++----- .../features/shop/services/shop.service.ts | 17 ++++- 9 files changed, 165 insertions(+), 33 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java index 1043025..cccb415 100644 --- a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java +++ b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java @@ -62,6 +62,12 @@ public class PublicShopController { return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang)); } + @GetMapping("/products/by-id-prefix/{idPrefix}") + public ResponseEntity getProductByIdPrefix(@PathVariable String idPrefix, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getProductByIdPrefix(idPrefix, lang)); + } + @GetMapping("/products/{slug}/model") public ResponseEntity getProductModel(@PathVariable String slug) throws IOException { PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index 1ecd99e..81c715d 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -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 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 entries, Map entriesBySlug, Map entriesByPublicPath, + Map entriesByIdPrefix, Map> productMediaBySlug, Map variantColorHexByMaterialAndColor ) { diff --git a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java index 9c182d1..b35e58a 100644 --- a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java @@ -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(); diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts index f165c08..0fcdfcc 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts @@ -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'); diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index 59b7ff7..656c291 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -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; diff --git a/frontend/src/app/features/shop/product-detail.component.spec.ts b/frontend/src/app/features/shop/product-detail.component.spec.ts index 08c0cf9..dc09961 100644 --- a/frontend/src/app/features/shop/product-detail.component.spec.ts +++ b/frontend/src/app/features/shop/product-detail.component.spec.ts @@ -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', [ '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(); diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 3573be6..b3003bd 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -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)}`; diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts index c85a0a5..1c4ef78 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -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'); }); }); diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index 7b5d215..f21949a 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -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( - `${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 { 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 { From 8835175fb3547e03048caa0350f46b0e3d224027 Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Wed, 25 Mar 2026 10:46:11 +0000 Subject: [PATCH 3/3] style: apply prettier formatting --- .../app/core/interceptors/server-origin.interceptor.ts | 4 +++- .../src/app/features/shop/services/shop.service.spec.ts | 6 ++++-- frontend/src/app/features/shop/services/shop.service.ts | 9 +++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index 656c291..5a1e3ca 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -137,7 +137,9 @@ export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { return next(req); } - const request = inject(REQUEST, { optional: true }) as ServerRequestLike | null; + const request = inject(REQUEST, { + optional: true, + }) as ServerRequestLike | null; const origin = resolveApiOrigin(request, req.url); if (!origin) { return next(req); diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts index 1c4ef78..9ed2b84 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -220,7 +220,8 @@ describe('ShopService', () => { next: (product) => { response = product; }, - error: () => fail('Expected stale slug tails to resolve from the uuid prefix'), + error: () => + fail('Expected stale slug tails to resolve from the uuid prefix'), }); const request = httpMock.expectOne((request) => { @@ -243,7 +244,8 @@ describe('ShopService', () => { next: (product) => { response = product; }, - error: () => fail('Expected bare uuid path to resolve from the uuid prefix'), + error: () => + fail('Expected bare uuid path to resolve from the uuid prefix'), }); const request = httpMock.expectOne((request) => { diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index f21949a..6f4cffe 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -295,12 +295,9 @@ export class ShopService { ? `${this.apiUrl}/products/by-id-prefix/${encodeURIComponent(productIdPrefix)}` : `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`; - return this.http.get( - endpoint, - { - params: this.buildLangParams(), - }, - ); + return this.http.get(endpoint, { + params: this.buildLangParams(), + }); } private normalizePublicPath(value: string | null | undefined): string {