From 9611049e01d4e22a15f60a5d716012065b872c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 24 Mar 2026 13:17:25 +0100 Subject: [PATCH] fix(front-end): new test --- .../server-origin.interceptor.spec.ts | 77 ++++++ .../shop/product-detail.component.spec.ts | 237 ++++++++++++++++++ .../features/shop/shop-page.component.spec.ts | 219 ++++++++++++++++ 3 files changed, 533 insertions(+) create mode 100644 frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts create mode 100644 frontend/src/app/features/shop/product-detail.component.spec.ts create mode 100644 frontend/src/app/features/shop/shop-page.component.spec.ts diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts new file mode 100644 index 0000000..403fc8d --- /dev/null +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { REQUEST } from '@angular/core'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { serverOriginInterceptor } from './server-origin.interceptor'; + +describe('serverOriginInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverOriginInterceptor])), + provideHttpClientTesting(), + { + provide: REQUEST, + useValue: { + protocol: 'https', + headers: { + host: 'dev.3d-fab.ch', + authorization: 'Basic dGVzdDp0ZXN0', + cookie: 'session=abc123', + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8', + }, + }, + }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => { + 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')).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('does not overwrite explicit request headers', () => { + http + .get('/api/shop/products', { + headers: { + authorization: 'Bearer explicit-token', + }, + }) + .subscribe(); + + const request = httpMock.expectOne( + 'https://dev.3d-fab.ch/api/shop/products', + ); + expect(request.request.headers.get('authorization')).toBe( + 'Bearer explicit-token', + ); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + request.flush({}); + }); +}); diff --git a/frontend/src/app/features/shop/product-detail.component.spec.ts b/frontend/src/app/features/shop/product-detail.component.spec.ts new file mode 100644 index 0000000..3e73a9d --- /dev/null +++ b/frontend/src/app/features/shop/product-detail.component.spec.ts @@ -0,0 +1,237 @@ +import { Location } from '@angular/common'; +import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; +import { ShopRouteService } from './services/shop-route.service'; +import { ShopProductDetail, ShopService } from './services/shop.service'; +import { ProductDetailComponent } from './product-detail.component'; + +describe('ProductDetailComponent', () => { + function buildProduct( + overrides: Partial = {}, + ): ShopProductDetail { + return { + id: '91823f84-1111-2222-3333-444444444444', + slug: 'bike-wall-hanger', + name: 'Bike Wall-Hanger', + excerpt: 'Wall mount for bicycles', + description: '

Wall mount for bicycles

', + seoTitle: null, + seoDescription: null, + ogTitle: null, + ogDescription: null, + indexable: true, + isFeatured: false, + sortOrder: 0, + category: { + id: 'category-1', + slug: 'bike-accessories', + name: 'Bike Accessories', + }, + breadcrumbs: [], + priceFromChf: 29.9, + priceToChf: 29.9, + defaultVariant: { + id: 'variant-1', + sku: 'BW-1', + variantLabel: 'PLA', + colorName: 'Black', + colorLabel: 'Black', + colorHex: '#111111', + priceChf: 29.9, + isDefault: true, + }, + variants: [ + { + id: 'variant-1', + sku: 'BW-1', + variantLabel: 'PLA', + colorName: 'Black', + colorLabel: 'Black', + colorHex: '#111111', + priceChf: 29.9, + isDefault: true, + }, + ], + primaryImage: null, + images: [], + model3d: null, + publicPath: '91823f84-bike-wall-hanger', + localizedPaths: { + it: '/it/shop/p/91823f84-supporto-bici-muro', + en: '/en/shop/p/91823f84-bike-wall-hanger', + de: '/de/shop/p/91823f84-bike-wall-hanger', + fr: '/fr/shop/p/91823f84-support-mural-velo', + }, + ...overrides, + }; + } + + function createComponent(routerUrl = '/de/shop/p/91823f84-bike-wall-hanger') { + const responseInit: { status?: number } = {}; + const seoService = jasmine.createSpyObj('SeoService', [ + 'applyResolvedSeo', + 'applyPageSeo', + ]); + const translate = jasmine.createSpyObj( + 'TranslateService', + ['instant'], + ); + translate.instant.and.callFake((key: string) => { + const translations: Record = { + 'SHOP.TITLE': 'Technische Lösungen', + 'SHOP.CATALOG_META_DESCRIPTION': + 'Entdecken Sie technische 3D-Druck-Lösungen.', + 'SEO.ROUTES.SHOP.PRODUCT_TITLE': 'Produkt | 3D fab', + 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION': + 'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.', + }; + return translations[key] ?? key; + }); + + const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de'); + const languageService = { + currentLang, + selectedLang: () => currentLang(), + setLocalizedRouteOverrides: jasmine.createSpy('setLocalizedRouteOverrides'), + clearLocalizedRouteOverrides: jasmine.createSpy( + 'clearLocalizedRouteOverrides', + ), + }; + + const shopService = { + cartLoaded: signal(false), + cartLoading: signal(false), + getProductByPublicPath: jasmine + .createSpy('getProductByPublicPath') + .and.returnValue(of(buildProduct())), + quantityForVariant: jasmine + .createSpy('quantityForVariant') + .and.returnValue(0), + loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)), + resolveMediaUrl: jasmine.createSpy('resolveMediaUrl').and.returnValue(null), + }; + + const router = { + url: routerUrl, + navigate: jasmine.createSpy('navigate'), + navigateByUrl: jasmine.createSpy('navigateByUrl'), + parseUrl: jasmine.createSpy('parseUrl'), + createUrlTree: jasmine.createSpy('createUrlTree'), + serializeUrl: jasmine.createSpy('serializeUrl'), + } as unknown as Router; + + const activatedRoute = { + paramMap: of(convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' })), + snapshot: { + paramMap: convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }), + }, + } as unknown as ActivatedRoute; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ProductDetailComponent], + providers: [ + { provide: SeoService, useValue: seoService }, + { provide: TranslateService, useValue: translate }, + { provide: LanguageService, useValue: languageService }, + { provide: ShopService, useValue: shopService }, + { + provide: ShopRouteService, + useValue: jasmine.createSpyObj('ShopRouteService', [ + 'shopRootCommands', + 'productPathSegment', + 'isCatalogUrl', + ]), + }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { + provide: Location, + useValue: jasmine.createSpyObj('Location', ['back']), + }, + { provide: RESPONSE_INIT, useValue: responseInit }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + const fixture: ComponentFixture = + TestBed.createComponent(ProductDetailComponent); + + return { + component: fixture.componentInstance, + seoService, + responseInit, + }; + } + + it('applies index follow SEO for indexable products', () => { + const { component, seoService } = createComponent(); + + (component as any).applySeo(buildProduct()); + + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Bike Wall-Hanger | 3D fab', + robots: 'index, follow', + canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger', + alternates: buildProduct().localizedPaths, + xDefault: '/it/shop/p/91823f84-supporto-bici-muro', + }), + ); + }); + + it('applies noindex for products explicitly marked as non-indexable', () => { + const { component, seoService } = createComponent(); + + (component as any).applySeo(buildProduct({ indexable: false })); + + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + }), + ); + }); + + it('builds a soft SSR fallback with 200 + index follow', () => { + const { component, seoService, responseInit } = createComponent(); + + expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue(); + (component as any).setResponseStatus(200); + (component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger'); + + expect(responseInit.status).toBe(200); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Bike Wall Hanger | 3D fab', + description: + 'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.', + robots: 'index, follow', + canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger', + alternates: null, + xDefault: null, + }), + ); + }); + + it('keeps hard fallback noindex for missing products', () => { + const { component, seoService, responseInit } = createComponent(); + + expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse(); + (component as any).setResponseStatus(404); + (component as any).applyHardFallbackSeo(); + + expect(responseInit.status).toBe(404); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + alternates: null, + xDefault: null, + }), + ); + }); +}); diff --git a/frontend/src/app/features/shop/shop-page.component.spec.ts b/frontend/src/app/features/shop/shop-page.component.spec.ts new file mode 100644 index 0000000..048234a --- /dev/null +++ b/frontend/src/app/features/shop/shop-page.component.spec.ts @@ -0,0 +1,219 @@ +import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; +import { + ShopCategoryDetail, + ShopCategoryTree, + ShopProductCatalogResponse, + ShopService, +} from './services/shop.service'; +import { ShopRouteService } from './services/shop-route.service'; +import { ShopPageComponent } from './shop-page.component'; + +describe('ShopPageComponent', () => { + function buildCategory( + overrides: Partial = {}, + ): ShopCategoryDetail { + return { + id: 'cat-1', + slug: 'compatible-with-garmin', + name: 'Compatible with Garmin', + description: 'Accessories compatible with Garmin devices.', + seoTitle: null, + seoDescription: null, + ogTitle: null, + ogDescription: null, + indexable: true, + sortOrder: 0, + productCount: 3, + breadcrumbs: [], + primaryImage: null, + images: [], + children: [], + ...overrides, + }; + } + + function buildCatalog( + overrides: Partial = {}, + ): ShopProductCatalogResponse { + return { + categorySlug: null, + featuredOnly: null, + category: null, + products: [], + ...overrides, + }; + } + + function createComponent(routerUrl = '/de/shop') { + const responseInit: { status?: number } = {}; + const seoService = jasmine.createSpyObj('SeoService', [ + 'applyResolvedSeo', + 'applyPageSeo', + ]); + const translate = jasmine.createSpyObj( + 'TranslateService', + ['instant'], + ); + translate.instant.and.callFake((key: string, params?: { count?: number }) => { + const translations: Record = { + 'SHOP.TITLE': 'Technische Lösungen', + 'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen', + 'SHOP.CATALOG_TITLE': 'Alle Produkte', + 'SHOP.CATALOG_LABEL': 'Katalog', + 'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie', + 'SHOP.CATALOG_META_DESCRIPTION': + 'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.', + 'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab', + 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION': + 'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.', + }; + if (key === 'SHOP.CATEGORY_META') { + return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`; + } + return translations[key] ?? key; + }); + + const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de'); + const languageService = { + currentLang, + selectedLang: () => currentLang(), + }; + + const shopService = { + cart: signal(null), + cartLoading: signal(false), + cartLoaded: signal(false), + cartItemCount: signal(0), + cartSessionId: signal(null), + getCategories: jasmine + .createSpy('getCategories') + .and.returnValue(of([] as ShopCategoryTree[])), + getProductCatalog: jasmine + .createSpy('getProductCatalog') + .and.returnValue(of(buildCatalog())), + flattenCategoryTree: jasmine + .createSpy('flattenCategoryTree') + .and.returnValue([]), + quantityForProduct: jasmine.createSpy('quantityForProduct').and.returnValue(0), + loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)), + clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)), + removeCartItem: jasmine.createSpy('removeCartItem').and.returnValue(of(null)), + updateCartItem: jasmine.createSpy('updateCartItem').and.returnValue(of(null)), + }; + + const router = { + url: routerUrl, + navigate: jasmine.createSpy('navigate'), + } as unknown as Router; + + const activatedRoute = { + paramMap: of(convertToParamMap({})), + snapshot: { + paramMap: convertToParamMap({}), + }, + } as unknown as ActivatedRoute; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ShopPageComponent], + providers: [ + { provide: SeoService, useValue: seoService }, + { provide: TranslateService, useValue: translate }, + { provide: LanguageService, useValue: languageService }, + { provide: ShopService, useValue: shopService }, + { + provide: ShopRouteService, + useValue: jasmine.createSpyObj('ShopRouteService', [ + 'shopRootCommands', + ]), + }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: RESPONSE_INIT, useValue: responseInit }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + const fixture: ComponentFixture = + TestBed.createComponent(ShopPageComponent); + + return { + component: fixture.componentInstance, + seoService, + responseInit, + }; + } + + it('keeps index follow on the public shop root', () => { + const { component, seoService } = createComponent(); + + (component as any).applyDefaultSeo(); + + expect(seoService.applyPageSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Technische Lösungen | 3D fab', + robots: 'index, follow', + }), + ); + }); + + it('keeps noindex for categories explicitly marked as non-indexable', () => { + const { component, seoService } = createComponent('/de/shop/compatible-with-garmin'); + + (component as any).applySeo(buildCategory({ indexable: false })); + + expect(seoService.applyPageSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + }), + ); + }); + + it('uses a soft SSR fallback for non-404 category load errors', () => { + const { component, seoService, responseInit } = createComponent( + '/de/shop/compatible-with-garmin', + ); + + expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue(); + (component as any).setResponseStatus(200); + (component as any).applySoftFallbackSeo('compatible-with-garmin'); + + expect(responseInit.status).toBe(200); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Compatible With Garmin | Technische Lösungen | 3D fab', + description: + 'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.', + robots: 'index, follow', + canonicalPath: '/de/shop/compatible-with-garmin', + alternates: null, + xDefault: null, + }), + ); + }); + + it('keeps hard 404 noindex behavior for missing categories', () => { + const { component, seoService, responseInit } = createComponent( + '/de/shop/compatible-with-garmin', + ); + + expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse(); + (component as any).setResponseStatus(404); + (component as any).applyHardErrorSeo(); + + expect(responseInit.status).toBe(404); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + alternates: null, + xDefault: null, + }), + ); + }); +});