From f5ccd213bcce43ba4901488104f85ab9a6eec4ed Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Thu, 24 Sep 2026 01:09:45 +0200 Subject: [PATCH] fix: load product images through the API instead of the storefront origin Closes #8 Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 ++ src/app/core/api.service.spec.ts | 57 ++++++++++++++++++++++++++++++++ src/app/core/api.service.ts | 18 ++++++++-- 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/app/core/api.service.spec.ts diff --git a/README.md b/README.md index 83250ad..f7b1ed3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ docker compose up --build -d Open `http://localhost:4300`. The Keycloak realm must contain the storefront redirect/web origin for this exact port, as provided by the backend realm import. +Product images come from the API as paths relative to its root (`/v1/items/{uuid}/image`); `ApiService` resolves them against `apiUrl`, so they load through the same `/api` proxy as every other call. + The item-store inventory fields are displayed as catalogue metadata. The checkout endpoint is responsible for validating and reserving authoritative stock from the separate inventory service. ## Licence diff --git a/src/app/core/api.service.spec.ts b/src/app/core/api.service.spec.ts new file mode 100644 index 0000000..e7f522f --- /dev/null +++ b/src/app/core/api.service.spec.ts @@ -0,0 +1,57 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ApiService } from './api.service'; +import { Item } from '../models'; + +/** + * The API answers media as paths relative to its own root ("/v1/items/…/image"). + * The browser resolves those against the storefront, which is not where the API + * lives, so every image broke. These pin the rewrite onto the configured API. + */ +describe('ApiService media URLs', () => { + let api: ApiService; + let http: HttpTestingController; + + const item = (media: Item['media']) => ({ uuid: 'u1', name: 'Barolo', media }) as Item; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + api = TestBed.inject(ApiService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + it('resolves API-relative images of a single item against the API', () => { + let result: Item | undefined; + api.item('u1').subscribe((i) => (result = i)); + http + .expectOne('/api/v1/items/u1') + .flush(item({ main_image: '/v1/items/u1/image', gallery: ['/v1/items/u1/image'] })); + + expect(result?.media.main_image).toBe('/api/v1/items/u1/image'); + expect(result?.media.gallery).toEqual(['/api/v1/items/u1/image']); + }); + + it('resolves images in a catalogue page and leaves absolute or missing ones alone', () => { + let result: Item[] = []; + api.items().subscribe((page) => (result = page.items)); + http + .expectOne((r) => r.url === '/api/v1/items/') + .flush({ + items: [ + item({ main_image: '/v1/items/u1/image', gallery: [] }), + item({ main_image: 'https://cdn.example/p.png', gallery: [] }), + item({ main_image: null, gallery: [] }), + ], + }); + + expect(result.map((i) => i.media.main_image)).toEqual([ + '/api/v1/items/u1/image', + 'https://cdn.example/p.png', + null, + ]); + }); +}); diff --git a/src/app/core/api.service.ts b/src/app/core/api.service.ts index 4a6036b..d1eeb5f 100644 --- a/src/app/core/api.service.ts +++ b/src/app/core/api.service.ts @@ -1,15 +1,29 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; +import { map } from 'rxjs'; import { Address, AddressInput, CheckoutResponse, Customer, Item, ItemPage, Order } from '../models'; import { storefrontConfig } from '../storefront.config'; +// The API answers media as paths from its own root ("/v1/items/…/image"); the +// browser would resolve those against the storefront, so prefix the API root. +const apiRoot = storefrontConfig.apiUrl.replace(/\/v1\/?$/, ''); +const fromApi = (path: string) => (path.startsWith('/') ? apiRoot + path : path); +const withApiMedia = (item: Item): Item => ({ + ...item, + media: { + ...item.media, + main_image: item.media?.main_image && fromApi(item.media.main_image), + gallery: (item.media?.gallery ?? []).map(fromApi), + }, +}); + @Injectable({ providedIn: 'root' }) export class ApiService { private readonly http = inject(HttpClient); private readonly base = storefrontConfig.apiUrl; - items(skip = 0, limit = 50) { return this.http.get(`${this.base}/items/`, { params: new HttpParams().set('skip', skip).set('limit', limit).set('status', 'active') }); } - item(id: string) { return this.http.get(`${this.base}/items/${encodeURIComponent(id)}`); } + items(skip = 0, limit = 50) { return this.http.get(`${this.base}/items/`, { params: new HttpParams().set('skip', skip).set('limit', limit).set('status', 'active') }).pipe(map((page) => ({ ...page, items: page.items.map(withApiMedia) }))); } + item(id: string) { return this.http.get(`${this.base}/items/${encodeURIComponent(id)}`).pipe(map(withApiMedia)); } me() { return this.http.get(`${this.base}/customers/me`); } updateMe(body: Partial>) { return this.http.patch(`${this.base}/customers/me`, body); } addresses() { return this.http.get(`${this.base}/customers/me/addresses`); }