Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/app/core/api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
18 changes: 16 additions & 2 deletions src/app/core/api.service.ts
Original file line number Diff line number Diff line change
@@ -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<ItemPage>(`${this.base}/items/`, { params: new HttpParams().set('skip', skip).set('limit', limit).set('status', 'active') }); }
item(id: string) { return this.http.get<Item>(`${this.base}/items/${encodeURIComponent(id)}`); }
items(skip = 0, limit = 50) { return this.http.get<ItemPage>(`${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<Item>(`${this.base}/items/${encodeURIComponent(id)}`).pipe(map(withApiMedia)); }
me() { return this.http.get<Customer>(`${this.base}/customers/me`); }
updateMe(body: Partial<Pick<Customer, 'email'|'first_name'|'last_name'>>) { return this.http.patch<Customer>(`${this.base}/customers/me`, body); }
addresses() { return this.http.get<Address[]>(`${this.base}/customers/me/addresses`); }
Expand Down
Loading