diff --git a/libs/integrations/allegro/src/infrastructure/mappers/__tests__/allegro-category-parameter.mapper.spec.ts b/libs/integrations/allegro/src/infrastructure/mappers/__tests__/allegro-category-parameter.mapper.spec.ts index c0b6f83d4..4364e5ed2 100644 --- a/libs/integrations/allegro/src/infrastructure/mappers/__tests__/allegro-category-parameter.mapper.spec.ts +++ b/libs/integrations/allegro/src/infrastructure/mappers/__tests__/allegro-category-parameter.mapper.spec.ts @@ -9,6 +9,12 @@ * - all other parameters have no parameter-level dependency and empty * `dependsOnValueIds` on their entries. * + * Parity note (#1382 review): `libs/integrations/erli/src/infrastructure/http + * /__tests__/allegro-category-catalog-client.spec.ts` runs this same fixture + * through `AllegroCategoryCatalogClient`'s independently-maintained copy of + * `toNeutralCategoryParameter` with mirrored assertions — a change to this + * mapper's behavior should prompt updating both spec files. + * * @module libs/integrations/allegro/src/infrastructure/mappers */ import { readFileSync } from 'fs'; diff --git a/libs/integrations/erli/src/domain/types/erli-connection.types.ts b/libs/integrations/erli/src/domain/types/erli-connection.types.ts index 186b790d9..2b9f041c5 100644 --- a/libs/integrations/erli/src/domain/types/erli-connection.types.ts +++ b/libs/integrations/erli/src/domain/types/erli-connection.types.ts @@ -8,6 +8,14 @@ * confirmed by the #992 spike) drops in via `connection.config.baseUrl` with no * code change. * + * `allegroClientId` / `allegroClientSecret` / `allegroEnvironment` (#1382, + * ADR-030) are an optional, separate Allegro app credential pair + environment + * selector — unrelated to Erli's own `apiKey` — that let an Erli connection + * browse Allegro's public category/parameter catalog via + * `grant_type=client_credentials` (see `AllegroCategoryCatalogClient`). The two + * credential fields are "both or neither"; enforced by + * `ErliConnectionCredentialsShapeValidatorAdapter` (#1383), not here. + * * Lives in `domain/types/` (not infrastructure) so the application-layer * factory can depend on it without inverting the hexagonal layer direction — * mirrors the Allegro/PrestaShop connection-type layout. @@ -15,9 +23,23 @@ * @module libs/integrations/erli/src/domain/types */ +/** + * Allegro environment the `AllegroCategoryCatalogClient` resolves its token + + * REST hosts against (#1382, ADR-030). Mirrors `AllegroConnectionConfig + * .environment`'s `as const` + runtime-array convention — kept local rather + * than imported from `@openlinker/integrations-allegro`, since plugin + * packages are architecturally independent (ADR-030). + */ +export const AllegroCatalogEnvironmentValues = ['sandbox', 'production'] as const; +export type AllegroCatalogEnvironment = (typeof AllegroCatalogEnvironmentValues)[number]; + /** Encrypted credentials for an Erli connection (resolved via host.credentialsResolver). */ export interface ErliCredentials { apiKey: string; + /** Allegro app client id (client_credentials grant) — optional, catalog-browsing only (#1382). */ + allegroClientId?: string; + /** Allegro app client secret (client_credentials grant) — optional, catalog-browsing only (#1382). */ + allegroClientSecret?: string; } /** @@ -52,6 +74,13 @@ export interface ErliConnectionConfig { * e.g. `http://host.docker.internal:3000` in dev, the public OL URL in prod. */ callbackBaseUrl?: string; + /** + * Allegro environment to resolve the `AllegroCategoryCatalogClient`'s token + * and REST hosts against (#1382, ADR-030) — mirrors `AllegroConnectionConfig + * .environment`'s convention. Defaults to `'production'` when absent. Only + * meaningful when `allegroClientId`/`allegroClientSecret` are configured. + */ + allegroEnvironment?: AllegroCatalogEnvironment; } /** diff --git a/libs/integrations/erli/src/infrastructure/http/__tests__/allegro-category-catalog-client.spec.ts b/libs/integrations/erli/src/infrastructure/http/__tests__/allegro-category-catalog-client.spec.ts new file mode 100644 index 000000000..70832e3af --- /dev/null +++ b/libs/integrations/erli/src/infrastructure/http/__tests__/allegro-category-catalog-client.spec.ts @@ -0,0 +1,432 @@ +/** + * Allegro Category Catalog Client — unit tests + * + * Stubs `global.fetch` (sibling convention, see `erli-http-client.spec.ts`) to + * verify client-credentials token acquisition, the proactive-refresh-window + * cache, the category/parameter response mapping, and typed-exception + * classification for a rejected token request and a network failure. + * + * Parity note (#1382 review): the "cross-plugin mapper parity" block below + * runs Allegro's own real sandbox fixture through this client's independently + * -maintained copy of `toNeutralCategoryParameter`, with assertions mirrored + * from `allegro-category-parameter.mapper.spec.ts` — a change to either + * mapper's behavior should prompt updating both spec files. + * + * @module libs/integrations/erli/src/infrastructure/http + */ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { ErliAuthenticationException } from '../../../domain/exceptions/erli-authentication.exception'; +import { ErliNetworkException } from '../../../domain/exceptions/erli-network.exception'; +import { AllegroCategoryCatalogClient } from '../allegro-category-catalog-client'; + +/** Allegro's real sandbox capture (cat 257933, "Aparaty cyfrowe") — shared with `allegro-category-parameter.mapper.spec.ts`. */ +const ALLEGRO_FIXTURE_PATH = resolve( + __dirname, + '../../../../../allegro/src/infrastructure/adapters/__fixtures__/category-parameters-257933.json' +); + +interface AllegroFixtureParameter { + id: string; + options?: { describesProduct?: boolean }; + dictionary?: Array<{ dependsOnValueIds?: string[] }>; +} + +function loadAllegroFixture(): { parameters: AllegroFixtureParameter[] } { + return JSON.parse(readFileSync(ALLEGRO_FIXTURE_PATH, 'utf8')) as { + parameters: AllegroFixtureParameter[]; + }; +} + +function findFixtureParam( + fixture: { parameters: AllegroFixtureParameter[] }, + id: string +): AllegroFixtureParameter { + const found = fixture.parameters.find((p) => p.id === id); + if (!found) throw new Error(`Fixture missing parameter ${id}`); + return found; +} + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: (): Promise => Promise.resolve(body), + text: (): Promise => Promise.resolve(JSON.stringify(body)), + } as unknown as Response; +} + +/** Typed view of the recorded `fetch(url, init)` calls (avoids `any` access). */ +type RecordedCall = [url: string, init?: { method?: string; headers?: Record }]; +function recordedCalls(mock: jest.Mock): RecordedCall[] { + return mock.mock.calls as RecordedCall[]; +} + +const CLIENT_ID = 'test-client-id'; +const CLIENT_SECRET = 'test-client-secret'; + +describe('AllegroCategoryCatalogClient', () => { + let fetchMock: jest.Mock; + let client: AllegroCategoryCatalogClient; + + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + client = new AllegroCategoryCatalogClient(CLIENT_ID, CLIENT_SECRET, 'sandbox'); + }); + + describe('token acquisition', () => { + it('should acquire a token via grant_type=client_credentials on first call', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + + await client.fetchCategories(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [tokenUrl, tokenInit] = recordedCalls(fetchMock)[0]; + expect(tokenUrl).toBe('https://allegro.pl.allegrosandbox.pl/auth/oauth/token'); + expect(tokenInit?.method).toBe('POST'); + expect(tokenInit?.headers?.Authorization).toBe( + `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}` + ); + }); + + it('should reuse the cached token within the freshness window', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + + await client.fetchCategories(); + await client.fetchCategories(); + + // One token request + two category requests — token not re-acquired. + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should re-acquire the token after it expires', async () => { + const nowSpy = jest.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_000_000); + + fetchMock + .mockResolvedValueOnce( + // expires_in: 30s — well inside the 60s refresh window, so any + // later call proactively re-acquires. + jsonResponse(200, { access_token: 'app-token-1', expires_in: 30, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })) + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-2', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + + await client.fetchCategories(); + nowSpy.mockReturnValue(1_000_000 + 31_000); + await client.fetchCategories(); + + expect(fetchMock).toHaveBeenCalledTimes(4); + const secondCategoriesCall = recordedCalls(fetchMock)[3]; + expect(secondCategoriesCall[1]?.headers?.Authorization).toBe('Bearer app-token-2'); + + nowSpy.mockRestore(); + }); + + it('should throw ErliAuthenticationException for a non-2xx token response', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: 'invalid_client' })); + + await expect(client.fetchCategories()).rejects.toThrow(ErliAuthenticationException); + }); + + it('should throw ErliNetworkException when the token request fails at the network level', async () => { + fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect(client.fetchCategories()).rejects.toThrow(ErliNetworkException); + }); + }); + + describe('fetchCategories', () => { + beforeEach(() => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', expires_in: 3600, token_type: 'bearer' }) + ); + }); + + it('should map the raw category response to the neutral OfferCategory shape', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + categories: [ + { id: '1', name: 'Root', parent: null, leaf: false }, + { id: '2', name: 'Child', parent: { id: '1' }, leaf: true }, + ], + }) + ); + + const result = await client.fetchCategories('1'); + + expect(result).toEqual([ + { id: '1', name: 'Root', parentId: null, leaf: false }, + { id: '2', name: 'Child', parentId: '1', leaf: true }, + ]); + const [categoriesUrl] = recordedCalls(fetchMock)[1]; + expect(categoriesUrl).toBe( + 'https://api.allegro.pl.allegrosandbox.pl/sale/categories?parent.id=1' + ); + }); + + it('should request the root category list when parentId is omitted', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + + await client.fetchCategories(); + + const [categoriesUrl] = recordedCalls(fetchMock)[1]; + expect(categoriesUrl).toBe('https://api.allegro.pl.allegrosandbox.pl/sale/categories'); + }); + + it('should throw ErliNetworkException when the categories request fails at the network level', async () => { + fetchMock.mockRejectedValueOnce(new Error('ETIMEDOUT')); + + await expect(client.fetchCategories()).rejects.toThrow(ErliNetworkException); + }); + }); + + describe('fetchCategoryParameters', () => { + beforeEach(() => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', expires_in: 3600, token_type: 'bearer' }) + ); + }); + + it('should map the raw parameter response to the neutral CategoryParameter shape', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + parameters: [ + { + id: 'p1', + name: 'Stan', + type: 'dictionary', + required: true, + options: { describesProduct: false }, + dictionary: [ + { id: 'v1', value: 'Nowy' }, + { id: 'v2', value: 'Używany' }, + ], + restrictions: { multipleChoices: false }, + }, + { + id: 'p2', + name: 'Marka', + type: 'string', + required: false, + options: { describesProduct: true, customValuesEnabled: true }, + restrictions: { allowedNumberOfValues: 5 }, + }, + ], + }) + ); + + const result = await client.fetchCategoryParameters('123'); + + expect(result).toEqual([ + { + id: 'p1', + name: 'Stan', + type: 'dictionary', + required: true, + multiValue: false, + unit: undefined, + dictionary: [ + { id: 'v1', value: 'Nowy', dependsOnValueIds: undefined }, + { id: 'v2', value: 'Używany', dependsOnValueIds: undefined }, + ], + restrictions: { + multipleChoices: false, + range: undefined, + min: undefined, + max: undefined, + minLength: undefined, + maxLength: undefined, + precision: undefined, + allowedNumberOfValues: undefined, + customValuesEnabled: undefined, + }, + dependsOn: undefined, + section: 'offer', + }, + { + id: 'p2', + name: 'Marka', + type: 'string', + required: false, + multiValue: true, + unit: undefined, + dictionary: undefined, + restrictions: { + multipleChoices: undefined, + range: undefined, + min: undefined, + max: undefined, + minLength: undefined, + maxLength: undefined, + precision: undefined, + allowedNumberOfValues: 5, + customValuesEnabled: true, + }, + dependsOn: undefined, + section: 'product', + }, + ]); + const [parametersUrl] = recordedCalls(fetchMock)[1]; + expect(parametersUrl).toBe( + 'https://api.allegro.pl.allegrosandbox.pl/sale/categories/123/parameters' + ); + }); + + it('should surface parameter-level dependsOn from dictionary entry dependsOnValueIds', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + parameters: [ + { + id: 'p3', + name: 'Rozmiar', + type: 'dictionary', + required: false, + options: { dependsOnParameterId: 'p1' }, + dictionary: [ + { id: 'v3', value: 'S', dependsOnValueIds: ['v1'] }, + { id: 'v4', value: 'M', dependsOnValueIds: ['v1', 'v2'] }, + ], + }, + ], + }) + ); + + const result = await client.fetchCategoryParameters('123'); + + expect(result[0].dependsOn).toEqual({ + parameterId: 'p1', + valueIds: expect.arrayContaining(['v1', 'v2']), + }); + }); + }); + + describe('token single-flight', () => { + it('should issue exactly one token request for two concurrent calls on a cold cache', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })) + .mockResolvedValueOnce(jsonResponse(200, { parameters: [] })); + + await Promise.all([client.fetchCategories(), client.fetchCategoryParameters('1')]); + + const tokenRequests = recordedCalls(fetchMock).filter(([url]) => url.includes('/auth/oauth/token')); + expect(tokenRequests).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('should treat a token response missing expires_in as already-expired', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })) + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-2', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + + await client.fetchCategories(); + await client.fetchCategories(); + + // A token with no expires_in must never be treated as never-expiring — + // the second call re-acquires rather than reusing app-token-1 forever. + expect(fetchMock).toHaveBeenCalledTimes(4); + const secondCategoriesCall = recordedCalls(fetchMock)[3]; + expect(secondCategoriesCall[1]?.headers?.Authorization).toBe('Bearer app-token-2'); + }); + }); + + describe('auth rejection on a data call (distinct from the token endpoint)', () => { + it('should throw ErliAuthenticationException when /sale/categories itself rejects the bearer token', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'stale-token', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(401, { error: 'invalid_token' })); + + await expect(client.fetchCategories()).rejects.toThrow(ErliAuthenticationException); + }); + + it('should throw ErliAuthenticationException when /sale/categories/{id}/parameters rejects the bearer token', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'stale-token', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(403, { error: 'access_denied' })); + + await expect(client.fetchCategoryParameters('1')).rejects.toThrow(ErliAuthenticationException); + }); + }); + + describe('cross-plugin mapper parity (#1382 review — Allegro mapper drift guard)', () => { + let fixture: { parameters: AllegroFixtureParameter[] }; + + beforeAll(() => { + fixture = loadAllegroFixture(); + }); + + beforeEach(() => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', expires_in: 3600, token_type: 'bearer' }) + ); + }); + + it('should map every parameter in Allegro\'s real sandbox capture without throwing', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, fixture)); + + const result = await client.fetchCategoryParameters('257933'); + + expect(result).toHaveLength(fixture.parameters.length); + }); + + it("should emit section: 'product' for the Marka parameter (describesProduct: true)", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, fixture)); + const raw = findFixtureParam(fixture, '248811'); // "Marka" — same case as the Allegro spec + expect(raw.options?.describesProduct).toBe(true); + + const result = await client.fetchCategoryParameters('257933'); + + const neutral = result.find((p) => p.id === '248811'); + expect(neutral?.section).toBe('product'); + }); + + it("should emit section: 'offer' for the Stan parameter (describesProduct absent)", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, fixture)); + const raw = findFixtureParam(fixture, '11323'); // "Stan" — same case as the Allegro spec + expect(raw.options?.describesProduct).not.toBe(true); + + const result = await client.fetchCategoryParameters('257933'); + + const neutral = result.find((p) => p.id === '11323'); + expect(neutral?.section).toBe('offer'); + }); + + it('should build dependsOn from the parameter-level dependency + entry-value union, matching the Allegro mapper', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, fixture)); + const raw = findFixtureParam(fixture, '229205'); // "Stan opakowania" — same case as the Allegro spec + + const result = await client.fetchCategoryParameters('257933'); + + const neutral = result.find((p) => p.id === '229205'); + expect(neutral?.dependsOn?.parameterId).toBe('11323'); + const expectedValueIds = raw.dictionary?.[0]?.dependsOnValueIds ?? []; + expect(new Set(neutral?.dependsOn?.valueIds ?? [])).toEqual(new Set(expectedValueIds)); + }); + }); +}); diff --git a/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.ts b/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.ts new file mode 100644 index 000000000..886e2184a --- /dev/null +++ b/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.ts @@ -0,0 +1,273 @@ +/** + * Allegro Category Catalog Client + * + * Erli-owned, self-contained HTTP client that lets an Erli connection browse + * Allegro's public `/sale/categories` and `/sale/categories/{id}/parameters` + * catalog via an Allegro app's `grant_type=client_credentials` token — no + * seller/user OAuth context, no Allegro `Connection` required (#1382, + * ADR-030). + * + * Deliberately does NOT import anything from `@openlinker/integrations-allegro` + * — plugin packages are architecturally independent (ADR-003), and Erli must + * remain buildable/shippable on its own. The proactive-refresh-window token + * caching shape mirrors `AllegroConnectionTokenState` as a *design pattern* + * only, re-implemented here in a simplified form: client-credentials tokens + * are re-requested proactively before expiry rather than reactively refreshed + * on a 401 (there's no `refresh_token` for this grant). + * + * Category/parameter response mapping mirrors + * `AllegroOfferManagerAdapter.fetchCategories` / + * `.fetchCategoryParametersRaw` field-for-field (read there for the source of + * truth) — duplicated here per ADR-030 rather than shared, since sharing + * would require the forbidden cross-plugin dependency. + * + * Plain class, no NestJS/DI — constructed per-connection by + * `ErliAdapterFactory` (#1383) exactly like `ErliHttpClient`. + * + * @module libs/integrations/erli/src/infrastructure/http + */ +import type { + CategoryParameter, + CategoryParameterDictionaryEntry, + OfferCategory, +} from '@openlinker/core/listings'; +import type { AllegroCatalogEnvironment } from '../../domain/types/erli-connection.types'; +import { ErliAuthenticationException } from '../../domain/exceptions/erli-authentication.exception'; +import { ErliNetworkException } from '../../domain/exceptions/erli-network.exception'; +import type { + AllegroCategoriesResponse, + AllegroCategoryParameter, + AllegroCategoryParametersResponse, + AllegroTokenResponse, + CachedToken, +} from './allegro-category-catalog-client.types'; + +/** Allegro environment → web host (serves `/auth/oauth/token`). */ +const SANDBOX_WEB_BASE_URL = 'https://allegro.pl.allegrosandbox.pl'; +const PRODUCTION_WEB_BASE_URL = 'https://allegro.pl'; + +/** Allegro environment → REST host (serves `/sale/categories*`). */ +const SANDBOX_REST_API_BASE_URL = 'https://api.allegro.pl.allegrosandbox.pl'; +const PRODUCTION_REST_API_BASE_URL = 'https://api.allegro.pl'; + +const ALLEGRO_ACCEPT_HEADER = 'application/vnd.allegro.public.v1+json'; + +/** + * Proactive token-refresh window — re-request when the cached token is within + * this many ms of `expiresAt`, so a request never pays a wasted 401 round-trip + * after an idle period. Mirrors `AllegroConnectionTokenState.TOKEN_REFRESH_WINDOW_MS`. + */ +const TOKEN_REFRESH_WINDOW_MS = 60_000; + +export class AllegroCategoryCatalogClient { + private readonly webBaseUrl: string; + private readonly restApiBaseUrl: string; + private cached: CachedToken | undefined; + /** + * In-flight token acquisition, shared by concurrent `ensureToken()` callers + * so two parallel `fetchCategories`/`fetchCategoryParameters` calls on a + * cold cache issue one `grant_type=client_credentials` request, not two. + * Cleared once the request settles (success or failure) so a later call + * re-evaluates the cache/acquire decision fresh. + */ + private inFlightToken: Promise | undefined; + + constructor( + private readonly clientId: string, + private readonly clientSecret: string, + environment: AllegroCatalogEnvironment + ) { + this.webBaseUrl = environment === 'production' ? PRODUCTION_WEB_BASE_URL : SANDBOX_WEB_BASE_URL; + this.restApiBaseUrl = + environment === 'production' ? PRODUCTION_REST_API_BASE_URL : SANDBOX_REST_API_BASE_URL; + } + + /** + * `CategoryBrowser.fetchCategories` — `GET /sale/categories?parent.id=`. + * Mirrors `AllegroOfferManagerAdapter.fetchCategories`'s field mapping. + */ + async fetchCategories(parentId?: string): Promise { + const query = parentId ? `?parent.id=${encodeURIComponent(parentId)}` : ''; + const response = await this.requestJson( + `${this.restApiBaseUrl}/sale/categories${query}` + ); + const categories = response.categories ?? []; + return categories.map((cat) => ({ + id: cat.id, + name: cat.name, + parentId: cat.parent?.id ?? null, + leaf: cat.leaf, + })); + } + + /** + * `CategoryParametersReader.fetchCategoryParameters` — + * `GET /sale/categories/{categoryId}/parameters`. Mirrors + * `AllegroOfferManagerAdapter.fetchCategoryParametersRaw` + + * `toNeutralCategoryParameter`'s field mapping. + */ + async fetchCategoryParameters(categoryId: string): Promise { + const response = await this.requestJson( + `${this.restApiBaseUrl}/sale/categories/${encodeURIComponent(categoryId)}/parameters` + ); + return (response.parameters ?? []).map(toNeutralCategoryParameter); + } + + /** + * Returns a cached app token if it's more than {@link TOKEN_REFRESH_WINDOW_MS} + * away from expiry, else acquires a fresh one via `grant_type=client_credentials`. + */ + private async ensureToken(): Promise { + if ( + this.cached && + this.cached.expiresAt !== undefined && + Date.now() < this.cached.expiresAt - TOKEN_REFRESH_WINDOW_MS + ) { + return this.cached.accessToken; + } + if (!this.inFlightToken) { + this.inFlightToken = this.acquireToken().finally(() => { + this.inFlightToken = undefined; + }); + } + this.cached = await this.inFlightToken; + return this.cached.accessToken; + } + + private async acquireToken(): Promise { + const tokenUrl = `${this.webBaseUrl}/auth/oauth/token`; + const basicAuth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); + + let response: Response; + try { + response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(), + }); + } catch (cause) { + throw new ErliNetworkException( + `Allegro category-catalog token network failure: ${(cause as Error).message}`, + cause + ); + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new ErliAuthenticationException( + `Allegro category-catalog token request rejected (${response.status}): ${errorText}`, + response.status, + tokenUrl + ); + } + + const tokenData = (await response.json()) as AllegroTokenResponse; + return { + accessToken: tokenData.access_token, + expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined, + }; + } + + private async requestJson(url: string): Promise { + const accessToken = await this.ensureToken(); + + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: ALLEGRO_ACCEPT_HEADER, + }, + }); + } catch (cause) { + throw new ErliNetworkException( + `Allegro category-catalog network failure: ${(cause as Error).message}`, + cause + ); + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + if (response.status === 401 || response.status === 403) { + throw new ErliAuthenticationException( + `Allegro category-catalog request rejected (${response.status}): ${errorText}`, + response.status, + url + ); + } + throw new ErliNetworkException( + `Allegro category-catalog request failed (${response.status}): ${errorText}` + ); + } + + return (await response.json()) as T; + } +} + +/** + * Maps Allegro's raw category-parameter shape to the neutral `CategoryParameter` + * contract — field-for-field copy of + * `libs/integrations/allegro/src/infrastructure/mappers/allegro-category-parameter.mapper.ts` + * (kept in sync manually per ADR-030's no-cross-plugin-dependency decision). + * The `__tests__` spec runs this function against Allegro's own real sandbox + * fixture (`category-parameters-257933.json`) with assertions mirrored from + * `allegro-category-parameter.mapper.spec.ts` — touching either mapper should + * prompt updating the assertions in both spec files. + */ +function toNeutralCategoryParameter(raw: AllegroCategoryParameter): CategoryParameter { + const dependsOnParameterId = raw.options?.dependsOnParameterId; + const visibilityValueIds = dependsOnParameterId + ? unionEntryParentValues(raw.dictionary ?? []) + : []; + + return { + id: raw.id, + name: raw.name, + type: raw.type, + required: raw.required, + multiValue: + raw.restrictions?.multipleChoices === true || + (raw.restrictions?.allowedNumberOfValues ?? 1) > 1, + unit: raw.unit, + dictionary: raw.dictionary?.map(toNeutralEntry), + restrictions: { + multipleChoices: raw.restrictions?.multipleChoices, + range: raw.restrictions?.range, + min: raw.restrictions?.min, + max: raw.restrictions?.max, + minLength: raw.restrictions?.minLength, + maxLength: raw.restrictions?.maxLength, + precision: raw.restrictions?.precision, + allowedNumberOfValues: raw.restrictions?.allowedNumberOfValues, + customValuesEnabled: raw.options?.customValuesEnabled, + }, + dependsOn: + dependsOnParameterId && visibilityValueIds.length > 0 + ? { parameterId: dependsOnParameterId, valueIds: visibilityValueIds } + : undefined, + section: raw.options?.describesProduct === true ? 'product' : 'offer', + }; +} + +function toNeutralEntry( + raw: NonNullable[number] +): CategoryParameterDictionaryEntry { + return { + id: raw.id, + value: raw.value, + dependsOnValueIds: + raw.dependsOnValueIds && raw.dependsOnValueIds.length > 0 ? raw.dependsOnValueIds : undefined, + }; +} + +function unionEntryParentValues(dict: ReadonlyArray<{ dependsOnValueIds?: string[] }>): string[] { + const set = new Set(); + for (const entry of dict) { + for (const id of entry.dependsOnValueIds ?? []) set.add(id); + } + return [...set]; +} diff --git a/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.types.ts b/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.types.ts new file mode 100644 index 000000000..33d81936c --- /dev/null +++ b/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.types.ts @@ -0,0 +1,70 @@ +/** + * Allegro Category Catalog Client Types + * + * Wire-shape types for `AllegroCategoryCatalogClient` — the + * `grant_type=client_credentials` token response and the raw + * `/sale/categories` / `/sale/categories/{id}/parameters` response shapes. + * Extracted to a separate file per engineering-standards.md § "Type + * Definitions in Separate Files" — mirrors the sibling `erli-http-client.ts` + * → `erli-http-client.types.ts` split, and Allegro's own equivalent types at + * `domain/types/allegro-api.types.ts`. + * + * @module libs/integrations/erli/src/infrastructure/http + */ + +export interface AllegroTokenResponse { + access_token: string; + expires_in?: number; + token_type: string; +} + +/** Raw Allegro category item — `GET /sale/categories`. */ +export interface AllegroCategoryItem { + id: string; + name: string; + parent?: { id: string } | null; + leaf: boolean; +} + +export interface AllegroCategoriesResponse { + categories: AllegroCategoryItem[]; +} + +/** Raw Allegro category parameter — `GET /sale/categories/{id}/parameters`. */ +export interface AllegroCategoryParameter { + id: string; + name: string; + type: 'dictionary' | 'string' | 'integer' | 'float'; + required: boolean; + unit?: string; + options?: { + dependsOnParameterId?: string; + describesProduct?: boolean; + customValuesEnabled?: boolean; + }; + dictionary?: Array<{ + id: string; + value: string; + dependsOnValueIds?: string[]; + }>; + restrictions?: { + multipleChoices?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + range?: boolean; + precision?: number; + allowedNumberOfValues?: number; + }; +} + +export interface AllegroCategoryParametersResponse { + parameters: AllegroCategoryParameter[]; +} + +/** Cached client-credentials token state for one client instance. */ +export interface CachedToken { + accessToken: string; + expiresAt: number | undefined; +}