diff --git a/apps/api/test/integration/listings/erli-category-catalog.int-spec.ts b/apps/api/test/integration/listings/erli-category-catalog.int-spec.ts new file mode 100644 index 000000000..2f1435c5e --- /dev/null +++ b/apps/api/test/integration/listings/erli-category-catalog.int-spec.ts @@ -0,0 +1,279 @@ +/** + * Erli Category-Catalog Integration Test (#1383, ADR-031) + * + * Exercises the REAL `ErliAdapterFactory.createAdapters` credential check, the + * REAL `ErliOfferManagerAdapter` per-instance `fetchCategories`/ + * `fetchCategoryParameters` wiring, and the REAL `AllegroCategoryCatalogClient` + * HTTP flow (`global.fetch` stubbed — no real Allegro network calls) — through + * the production adapter-resolution seam (`IntegrationsService.getCapabilityAdapter`) + * and the real HTTP surface + * (`GET /listings/connections/:connectionId/categories/:categoryId/parameters`). + * + * Two connections, same adapterKey, differing only in resolved credentials: + * - WITH valid `allegroClientId`/`allegroClientSecret` → the endpoint returns + * real category-parameter data, and `isCategoryBrowser`/ + * `isCategoryParametersReader` are `true` on the resolved adapter instance. + * - WITHOUT (or with only one of the pair) → the endpoint 422s exactly like + * today's "adapter doesn't implement this capability" case — NOT a new + * error path (#1383 assumption verified against `CategoriesCacheService` / + * `ListingsController` unchanged). Guard functions are `false`. + * + * `connection.supportedCapabilities` (the connection-response DTO field) is + * asserted to stay IDENTICAL across both connections. It is populated from the + * static, per-adapterKey `AdapterMetadata.supportedCapabilities` — never + * connection-instance-aware — and ADR-031 deliberately does NOT add + * `CategoryBrowser`/`CategoryParametersReader` there: doing so would advertise + * the capability for every Erli connection regardless of configuration, + * exactly the regression ADR-031 rules out (and the #1367 bulk-wizard gate + * reads `supportedCapabilities`, not the runtime adapter). The real, + * per-connection-accurate signal is the guard functions on the resolved + * adapter instance and this HTTP endpoint's behaviour, which this spec + * exercises directly. + * + * @module apps/api/test/integration/listings + */ +import { randomUUID } from 'crypto'; +import type { DataSource } from 'typeorm'; +import request from 'supertest'; +import { encryptWithKey, loadEncryptionKey } from '@openlinker/shared'; +import { ConnectionOrmEntity } from '@openlinker/core/identifier-mapping/orm-entities'; +import { IntegrationCredentialOrmEntity } from '@openlinker/core/integrations/orm-entities'; +import type { + AdapterFactoryResolverService, + AdapterRegistryPort, + CredentialsResolverPort, + IIntegrationsService, +} from '@openlinker/core/integrations'; +import { + ADAPTER_FACTORY_RESOLVER_TOKEN, + ADAPTER_REGISTRY_TOKEN, + INTEGRATIONS_SERVICE_TOKEN, +} from '@openlinker/core/integrations'; +import type { Connection, IdentifierMappingPort } from '@openlinker/core/identifier-mapping'; +import { isCategoryBrowser, isCategoryParametersReader, type OfferManagerPort } from '@openlinker/core/listings'; +import { ErliAdapterFactory } from '@openlinker/integrations-erli/application/erli-adapter.factory'; + +import type { IntegrationTestHarness } from '../setup'; +import { getTestHarness, resetTestHarness, teardownTestHarness } from '../setup'; +import { loginAsAdmin } from '../helpers/test-auth.helper'; + +const TEST_ADAPTER_KEY = 'erli.catalog.test.v1'; +const TEST_PLATFORM_TYPE = 'erli'; +const CATEGORY_ID = '258066'; + +/** Registers the test adapterKey once, backed by the REAL `ErliAdapterFactory`. */ +function installErliCatalogTestFactory(harness: IntegrationTestHarness): void { + const app = harness.getApp(); + const adapterRegistry = app.get(ADAPTER_REGISTRY_TOKEN); + const factoryResolver = app.get(ADAPTER_FACTORY_RESOLVER_TOKEN); + + adapterRegistry.register({ + adapterKey: TEST_ADAPTER_KEY, + platformType: TEST_PLATFORM_TYPE, + // Mirrors the real `erliAdapterManifest` — deliberately does NOT list + // 'CategoryBrowser'/'CategoryParametersReader' (ADR-031: per-instance, + // never a static per-adapterKey capability). + supportedCapabilities: ['OfferManager'], + displayName: 'Erli OfferManager (integration-test, real factory)', + version: '0.0.0-test', + isDefault: false, + }); + + factoryResolver.registerFactory(TEST_ADAPTER_KEY, { + createCapabilityAdapter: async ( + connection: Connection, + _capability: string, + identifierMapping: IdentifierMappingPort, + credentialsResolver: CredentialsResolverPort, + ): Promise => { + // REAL factory — exercises the #1383 credential-check + AllegroCategoryCatalogClient + // wiring under test, not a hand-rolled substitute. + const adapters = await new ErliAdapterFactory().createAdapters( + connection, + identifierMapping, + credentialsResolver, + ); + return adapters.offerManager as unknown as T; + }, + }); +} + +async function seedErliConnection( + dataSource: DataSource, + credentials: Record, +): Promise { + const credentialsRef = `test-erli-catalog-${randomUUID()}`; + const { key } = loadEncryptionKey(process.env); + const credRepo = dataSource.getRepository(IntegrationCredentialOrmEntity); + await credRepo.save( + credRepo.create({ + ref: credentialsRef, + platformType: TEST_PLATFORM_TYPE, + credentialsCiphertext: encryptWithKey(key, JSON.stringify(credentials)), + }), + ); + + const connRepo = dataSource.getRepository(ConnectionOrmEntity); + const connection = await connRepo.save( + connRepo.create({ + platformType: TEST_PLATFORM_TYPE, + name: 'Test Erli connection (category catalog)', + status: 'active', + config: {}, + credentialsRef: `db:${credentialsRef}`, + adapterKey: TEST_ADAPTER_KEY, + enabledCapabilities: ['OfferManager'], + }), + ); + return connection.id; +} + +/** Fixture Allegro category-parameters response, mirroring the real wire shape. */ +function allegroCategoryParametersFixture(): { parameters: unknown[] } { + return { + parameters: [ + { + id: 'param-1', + name: 'Colour', + type: 'string', + required: false, + restrictions: {}, + }, + ], + }; +} + +describe('Erli Category-Catalog Integration (#1383, ADR-031)', () => { + let harness: IntegrationTestHarness; + let dataSource: DataSource; + let originalFetch: typeof fetch; + + beforeAll(async () => { + harness = await getTestHarness(); + dataSource = harness.getDataSource(); + installErliCatalogTestFactory(harness); + originalFetch = global.fetch; + }); + + afterEach(async () => { + global.fetch = originalFetch; + await resetTestHarness(); + }); + + afterAll(async () => { + await teardownTestHarness(); + }); + + function stubAllegroFetch(): jest.Mock { + const fetchMock = jest.fn().mockImplementation((url: string) => { + if (url.includes('/auth/oauth/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: (): Promise> => + Promise.resolve({ access_token: 'fake-allegro-token', expires_in: 3600, token_type: 'bearer' }), + } as unknown as Response); + } + if (url.includes(`/sale/categories/${CATEGORY_ID}/parameters`)) { + return Promise.resolve({ + ok: true, + status: 200, + json: (): Promise<{ parameters: unknown[] }> => + Promise.resolve(allegroCategoryParametersFixture()), + } as unknown as Response); + } + return Promise.reject(new Error(`Unexpected fetch call in test: ${url}`)); + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + } + + async function getOfferManagerAdapter(connectionId: string): Promise { + const integrations = harness.getApp().get(INTEGRATIONS_SERVICE_TOKEN); + return integrations.getCapabilityAdapter(connectionId, 'OfferManager'); + } + + async function getSupportedCapabilities( + http: ReturnType, + token: string, + connectionId: string, + ): Promise { + const response = await http + .get(`/v1/connections/${connectionId}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + return response.body.supportedCapabilities as string[]; + } + + describe('a connection WITH valid Allegro app credentials', () => { + it('exposes CategoryBrowser/CategoryParametersReader on the resolved adapter and returns real parameter data over HTTP', async () => { + stubAllegroFetch(); + const connectionId = await seedErliConnection(dataSource, { + apiKey: 'erli-key-not-real', + allegroClientId: 'client-1', + allegroClientSecret: 'secret-1', + }); + + const adapter = await getOfferManagerAdapter(connectionId); + expect(isCategoryBrowser(adapter)).toBe(true); + expect(isCategoryParametersReader(adapter)).toBe(true); + + const http = request(harness.getApp().getHttpServer()); + const token = await loginAsAdmin(http, dataSource); + + const response = await http + .get(`/v1/listings/connections/${connectionId}/categories/${CATEGORY_ID}/parameters`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(response.body.parameters).toEqual([ + expect.objectContaining({ id: 'param-1', name: 'Colour' }), + ]); + + // No static leak: supportedCapabilities is the static per-adapterKey set, + // never widened by per-connection Allegro credential configuration. + const supported = await getSupportedCapabilities(http, token, connectionId); + expect(supported).toEqual(['OfferManager']); + }); + }); + + describe('a connection WITHOUT Allegro app credentials', () => { + it('does NOT expose CategoryBrowser/CategoryParametersReader and the HTTP endpoint 422s like an unsupported capability', async () => { + const connectionId = await seedErliConnection(dataSource, { + apiKey: 'erli-key-not-real', + }); + + const adapter = await getOfferManagerAdapter(connectionId); + expect(isCategoryBrowser(adapter)).toBe(false); + expect(isCategoryParametersReader(adapter)).toBe(false); + + const http = request(harness.getApp().getHttpServer()); + const token = await loginAsAdmin(http, dataSource); + + const response = await http + .get(`/v1/listings/connections/${connectionId}/categories/${CATEGORY_ID}/parameters`) + .set('Authorization', `Bearer ${token}`) + .expect(422); + expect(response.body.message).toContain('does not support category-parameters reading'); + + const supported = await getSupportedCapabilities(http, token, connectionId); + expect(supported).toEqual(['OfferManager']); + }); + }); + + describe('a connection with only ONE of the Allegro credential pair', () => { + it('does NOT expose CategoryBrowser/CategoryParametersReader (both-or-neither, ADR-031)', async () => { + const connectionId = await seedErliConnection(dataSource, { + apiKey: 'erli-key-not-real', + allegroClientId: 'client-1', + // allegroClientSecret intentionally absent — a pre-existing/externally + // written row could carry this shape even though the shape validator + // rejects it at write time; the factory must still fail closed. + }); + + const adapter = await getOfferManagerAdapter(connectionId); + expect(isCategoryBrowser(adapter)).toBe(false); + expect(isCategoryParametersReader(adapter)).toBe(false); + }); + }); +}); diff --git a/libs/integrations/erli/src/application/__tests__/erli-adapter.factory.spec.ts b/libs/integrations/erli/src/application/__tests__/erli-adapter.factory.spec.ts index eb2de8c2a..b92c28cbf 100644 --- a/libs/integrations/erli/src/application/__tests__/erli-adapter.factory.spec.ts +++ b/libs/integrations/erli/src/application/__tests__/erli-adapter.factory.spec.ts @@ -9,7 +9,12 @@ */ import type { CredentialsResolverPort } from '@openlinker/core/integrations'; import type { Connection, IdentifierMappingPort } from '@openlinker/core/identifier-mapping'; -import { isOfferCreator, isOfferFieldUpdater } from '@openlinker/core/listings'; +import { + isCategoryBrowser, + isCategoryParametersReader, + isOfferCreator, + isOfferFieldUpdater, +} from '@openlinker/core/listings'; import { ErliConfigException } from '../../domain/exceptions/erli-config.exception'; import { ErliAdapterFactory } from '../erli-adapter.factory'; @@ -39,6 +44,7 @@ function okResponse(): Response { status: 200, headers: { get: (): string | null => null }, text: (): Promise => Promise.resolve('{}'), + json: (): Promise> => Promise.resolve({}), } as unknown as Response; } @@ -164,5 +170,78 @@ describe('ErliAdapterFactory', () => { ), ).rejects.toBeInstanceOf(ErliConfigException); }); + + it('should resolve credentials exactly once per call (#1399 review — no double resolve)', async () => { + const resolver = resolverFor({ + apiKey: 'k-123', + allegroClientId: 'client-1', + allegroClientSecret: 'secret-1', + }); + + await factory.createAdapters(connection(), {} as IdentifierMappingPort, resolver); + + expect(resolver.get).toHaveBeenCalledTimes(1); + }); + + describe('Allegro category-catalog wiring (#1382/#1383, ADR-031)', () => { + it('should NOT wire CategoryBrowser/CategoryParametersReader when Allegro credentials are absent', async () => { + const adapters = await factory.createAdapters( + connection(), + {} as IdentifierMappingPort, + resolverFor({ apiKey: 'k-123' }), + ); + + expect(isCategoryBrowser(adapters.offerManager)).toBe(false); + expect(isCategoryParametersReader(adapters.offerManager)).toBe(false); + }); + + it('should NOT wire CategoryBrowser/CategoryParametersReader when only one of the Allegro credential pair is present', async () => { + const adapters = await factory.createAdapters( + connection(), + {} as IdentifierMappingPort, + resolverFor({ apiKey: 'k-123', allegroClientId: 'client-1' }), + ); + + expect(isCategoryBrowser(adapters.offerManager)).toBe(false); + expect(isCategoryParametersReader(adapters.offerManager)).toBe(false); + }); + + it('should wire CategoryBrowser/CategoryParametersReader when both Allegro credentials are present', async () => { + const adapters = await factory.createAdapters( + connection(), + {} as IdentifierMappingPort, + resolverFor({ + apiKey: 'k-123', + allegroClientId: 'client-1', + allegroClientSecret: 'secret-1', + }), + ); + + expect(isCategoryBrowser(adapters.offerManager)).toBe(true); + expect(isCategoryParametersReader(adapters.offerManager)).toBe(true); + }); + + it('should resolve the Allegro sandbox host when config.allegroEnvironment is sandbox', async () => { + const adapters = await factory.createAdapters( + connection({ config: { allegroEnvironment: 'sandbox' } }), + {} as IdentifierMappingPort, + resolverFor({ + apiKey: 'k-123', + allegroClientId: 'client-1', + allegroClientSecret: 'secret-1', + }), + ); + expect(isCategoryBrowser(adapters.offerManager)).toBe(true); + + if (isCategoryBrowser(adapters.offerManager)) { + await adapters.offerManager.fetchCategories(); + } + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('allegrosandbox.pl'), + expect.anything(), + ); + }); + }); }); }); diff --git a/libs/integrations/erli/src/application/erli-adapter.factory.ts b/libs/integrations/erli/src/application/erli-adapter.factory.ts index 34b630adb..bbe1b1a87 100644 --- a/libs/integrations/erli/src/application/erli-adapter.factory.ts +++ b/libs/integrations/erli/src/application/erli-adapter.factory.ts @@ -10,6 +10,16 @@ * Not `@Injectable` — a plain class; the client it builds closes over one * connection's API key (ADR-025 static-key model, never a DI singleton). * + * Allegro category-catalog wiring (#1382/#1383, ADR-031): when the resolved + * credentials carry BOTH `allegroClientId` and `allegroClientSecret`, + * `createAdapters` also builds an `AllegroCategoryCatalogClient` (environment + * from `config.allegroEnvironment ?? 'production'`, sharing `host.cache` for + * its app-token cache — #1399 review) and passes it into the offer-manager + * constructor, which wires `fetchCategories`/`fetchCategoryParameters` as + * per-instance properties. Absent or partial credentials → `undefined` is + * passed instead, leaving those properties unset for this connection (never a + * static, connection-independent capability). + * * @module libs/integrations/erli/src/application */ import type { CredentialsResolverPort } from '@openlinker/core/integrations'; @@ -24,6 +34,7 @@ import { type ErliCredentials, } from '../domain/types/erli-connection.types'; import { ERLI_ADAPTER_KEY } from '../erli.constants'; +import { AllegroCategoryCatalogClient } from '../infrastructure/http/allegro-category-catalog-client'; import { ErliOfferManagerAdapter } from '../infrastructure/adapters/erli-offer-manager.adapter'; import { ErliOrderSourceAdapter } from '../infrastructure/adapters/erli-order-source.adapter'; import { ErliHttpClient } from '../infrastructure/http/erli-http-client'; @@ -63,8 +74,14 @@ export class ErliAdapterFactory implements IErliAdapterFactory { cache?: CachePort, inventoryQuery?: IInventoryQueryService, ): Promise { - const httpClient = await this.createHttpClient(connection, credentialsResolver); + // Resolved once and reused for both the Erli http client (apiKey) and the + // optional Allegro category-catalog client (allegroClientId/Secret) — a + // prior version resolved twice per call (#1399 review), paying a second + // credentialsRef DB round-trip + decrypt on every adapter construction. + const credentials = await this.resolveCredentials(connection, credentialsResolver); + const httpClient = this.buildHttpClient(connection, credentials); const config = (connection.config ?? {}) as ErliConnectionConfig; + const allegroCategoryCatalog = this.buildAllegroCategoryCatalog(credentials, config, cache); // Construct the offer manager first so its reference can be shared with the // order-source adapter (which needs it for the `cancelled` stock-restore path). const offerManager = new ErliOfferManagerAdapter( @@ -73,6 +90,7 @@ export class ErliAdapterFactory implements IErliAdapterFactory { httpClient, config.defaultDispatchTime, cache, + allegroCategoryCatalog, ); return { offerManager, @@ -98,9 +116,18 @@ export class ErliAdapterFactory implements IErliAdapterFactory { credentialsResolver: CredentialsResolverPort, retryConfig?: Partial, ): Promise { - const { apiKey } = await this.resolveCredentials(connection, credentialsResolver); + const credentials = await this.resolveCredentials(connection, credentialsResolver); + return this.buildHttpClient(connection, credentials, retryConfig); + } + + /** Shared by `createHttpClient` and `createAdapters` so a resolved `ErliCredentials` is never re-fetched to build the client. */ + private buildHttpClient( + connection: Connection, + credentials: ErliCredentials, + retryConfig?: Partial, + ): IErliHttpClient { const baseUrl = this.resolveBaseUrl(connection); - return new ErliHttpClient(connection.id, baseUrl, apiKey, retryConfig); + return new ErliHttpClient(connection.id, baseUrl, credentials.apiKey, retryConfig); } private async resolveCredentials( @@ -123,6 +150,33 @@ export class ErliAdapterFactory implements IErliAdapterFactory { return credentials; } + /** + * Build the optional Allegro category-catalog client (#1382/#1383, ADR-031). + * Requires BOTH `allegroClientId` and `allegroClientSecret` — re-checked here + * as defense-in-depth alongside `ErliConnectionCredentialsShapeValidatorAdapter` + * (a pre-existing or externally-written credentials row could carry exactly + * one). Absent or partial credentials return `undefined`, which the offer + * adapter treats as "no category browsing wired for this connection" — + * never a static, connection-independent capability. + */ + private buildAllegroCategoryCatalog( + credentials: ErliCredentials, + config: ErliConnectionConfig, + cache?: CachePort, + ): AllegroCategoryCatalogClient | undefined { + const clientId = credentials.allegroClientId?.trim(); + const clientSecret = credentials.allegroClientSecret?.trim(); + if (!clientId || !clientSecret) { + return undefined; + } + return new AllegroCategoryCatalogClient( + clientId, + clientSecret, + config.allegroEnvironment ?? 'production', + cache, + ); + } + private resolveBaseUrl(connection: Connection): string { const config = (connection.config ?? {}) as ErliConnectionConfig; const override = config.baseUrl?.trim(); 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 2b9f041c5..aaffb81dd 100644 --- a/libs/integrations/erli/src/domain/types/erli-connection.types.ts +++ b/libs/integrations/erli/src/domain/types/erli-connection.types.ts @@ -9,7 +9,7 @@ * code change. * * `allegroClientId` / `allegroClientSecret` / `allegroEnvironment` (#1382, - * ADR-030) are an optional, separate Allegro app credential pair + environment + * ADR-031) 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 @@ -25,10 +25,10 @@ /** * Allegro environment the `AllegroCategoryCatalogClient` resolves its token + - * REST hosts against (#1382, ADR-030). Mirrors `AllegroConnectionConfig + * REST hosts against (#1382, ADR-031). 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). + * packages are architecturally independent (ADR-031). */ export const AllegroCatalogEnvironmentValues = ['sandbox', 'production'] as const; export type AllegroCatalogEnvironment = (typeof AllegroCatalogEnvironmentValues)[number]; @@ -76,11 +76,20 @@ export interface ErliConnectionConfig { callbackBaseUrl?: string; /** * Allegro environment to resolve the `AllegroCategoryCatalogClient`'s token - * and REST hosts against (#1382, ADR-030) — mirrors `AllegroConnectionConfig + * and REST hosts against (#1382, ADR-031) — mirrors `AllegroConnectionConfig * .environment`'s convention. Defaults to `'production'` when absent. Only * meaningful when `allegroClientId`/`allegroClientSecret` are configured. */ allegroEnvironment?: AllegroCatalogEnvironment; + /** + * Non-secret, FE-visible mirror of "are `allegroClientId`/`allegroClientSecret` + * both configured" (#1383, ADR-031 "Correction"). `connection.supportedCapabilities` + * is a static, per-`adapterKey` manifest value — it is NOT computed per + * connection instance, so it cannot signal this. Whatever code path writes or + * clears the Allegro credential pair must set/clear this flag in the same + * operation, atomically, so it never drifts from the actual credential state. + */ + allegroCategoryAccessEnabled?: boolean; } /** diff --git a/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-config-shape-validator.adapter.spec.ts b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-config-shape-validator.adapter.spec.ts index 9b21151f5..50c48ca56 100644 --- a/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-config-shape-validator.adapter.spec.ts +++ b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-config-shape-validator.adapter.spec.ts @@ -108,4 +108,56 @@ describe('ErliConnectionConfigShapeValidatorAdapter', () => { errors: [{ path: 'callbackBaseUrl', message: expect.any(String) }], }); }); + + describe('allegroEnvironment (#1382/#1383, ADR-031)', () => { + it('should resolve when allegroEnvironment is absent', async () => { + await expect(validator.validate({})).resolves.toBeUndefined(); + }); + + it('should resolve when allegroEnvironment is "sandbox"', async () => { + await expect(validator.validate({ allegroEnvironment: 'sandbox' })).resolves.toBeUndefined(); + }); + + it('should resolve when allegroEnvironment is "production"', async () => { + await expect(validator.validate({ allegroEnvironment: 'production' })).resolves.toBeUndefined(); + }); + + it('should reject an unknown allegroEnvironment value', async () => { + await expect(validator.validate({ allegroEnvironment: 'staging' })).rejects.toMatchObject({ + errors: [{ path: 'allegroEnvironment', message: expect.any(String) }], + }); + }); + + it('should reject a non-string allegroEnvironment value', async () => { + await expect(validator.validate({ allegroEnvironment: 123 })).rejects.toMatchObject({ + errors: [{ path: 'allegroEnvironment', message: expect.any(String) }], + }); + }); + }); + + describe('allegroCategoryAccessEnabled (#1383, ADR-031 "Correction")', () => { + it('should resolve when allegroCategoryAccessEnabled is absent', async () => { + await expect(validator.validate({})).resolves.toBeUndefined(); + }); + + it('should resolve when allegroCategoryAccessEnabled is true', async () => { + await expect( + validator.validate({ allegroCategoryAccessEnabled: true }) + ).resolves.toBeUndefined(); + }); + + it('should resolve when allegroCategoryAccessEnabled is false', async () => { + await expect( + validator.validate({ allegroCategoryAccessEnabled: false }) + ).resolves.toBeUndefined(); + }); + + it('should reject a non-boolean allegroCategoryAccessEnabled value', async () => { + await expect( + validator.validate({ allegroCategoryAccessEnabled: 'true' }) + ).rejects.toMatchObject({ + errors: [{ path: 'allegroCategoryAccessEnabled', message: expect.any(String) }], + }); + }); + }); }); diff --git a/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-credentials-shape-validator.adapter.spec.ts b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-credentials-shape-validator.adapter.spec.ts index 01866b33a..4c11efa0c 100644 --- a/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-credentials-shape-validator.adapter.spec.ts +++ b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-connection-credentials-shape-validator.adapter.spec.ts @@ -35,4 +35,52 @@ describe('ErliConnectionCredentialsShapeValidatorAdapter', () => { it('should carry the plugin name in the rejection', async () => { await expect(validator.validate({})).rejects.toMatchObject({ pluginName: 'Erli' }); }); + + describe('allegroClientId/allegroClientSecret pair (#1382/#1383, ADR-031)', () => { + it('should resolve when neither Allegro credential is present', async () => { + await expect(validator.validate({ apiKey: 'secret-key' })).resolves.toBeUndefined(); + }); + + it('should resolve when both Allegro credentials are present as non-empty strings', async () => { + await expect( + validator.validate({ + apiKey: 'secret-key', + allegroClientId: 'client-1', + allegroClientSecret: 'secret-1', + }), + ).resolves.toBeUndefined(); + }); + + it('should reject when only allegroClientId is present', async () => { + await expect( + validator.validate({ apiKey: 'secret-key', allegroClientId: 'client-1' }), + ).rejects.toBeInstanceOf(InvalidCredentialsShapeException); + }); + + it('should reject when only allegroClientSecret is present', async () => { + await expect( + validator.validate({ apiKey: 'secret-key', allegroClientSecret: 'secret-1' }), + ).rejects.toBeInstanceOf(InvalidCredentialsShapeException); + }); + + it('should reject when both are present but one is an empty string', async () => { + await expect( + validator.validate({ + apiKey: 'secret-key', + allegroClientId: ' ', + allegroClientSecret: 'secret-1', + }), + ).rejects.toBeInstanceOf(InvalidCredentialsShapeException); + }); + + it('should reject when both are present but one is not a string', async () => { + await expect( + validator.validate({ + apiKey: 'secret-key', + allegroClientId: 123, + allegroClientSecret: 'secret-1', + }), + ).rejects.toBeInstanceOf(InvalidCredentialsShapeException); + }); + }); }); diff --git a/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-offer-manager.adapter.spec.ts b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-offer-manager.adapter.spec.ts index 4f6d61f2c..284588a31 100644 --- a/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-offer-manager.adapter.spec.ts +++ b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-offer-manager.adapter.spec.ts @@ -12,6 +12,8 @@ * @module libs/integrations/erli/src/infrastructure/adapters/__tests__ */ import { + isCategoryBrowser, + isCategoryParametersReader, isOfferStatusReader, OfferCreateRejectedException, OfferNotFoundOnMarketplaceException, @@ -26,6 +28,7 @@ import { ErliAuthenticationException } from '../../../domain/exceptions/erli-aut import { ErliConfigException } from '../../../domain/exceptions/erli-config.exception'; import { ErliNetworkException } from '../../../domain/exceptions/erli-network.exception'; import { ERLI_ADAPTER_KEY } from '../../../erli.constants'; +import type { AllegroCategoryCatalogClient } from '../../http/allegro-category-catalog-client'; import type { IErliHttpClient } from '../../http/erli-http-client.interface'; import { ErliOfferManagerAdapter, @@ -82,6 +85,75 @@ describe('ErliOfferManagerAdapter', () => { }); }); + describe('Allegro category-catalog wiring (#1383, ADR-031)', () => { + it('should NOT expose CategoryBrowser/CategoryParametersReader when constructed without a catalog client', () => { + expect(isCategoryBrowser(adapter)).toBe(false); + expect(isCategoryParametersReader(adapter)).toBe(false); + expect(adapter.fetchCategories).toBeUndefined(); + expect(adapter.fetchCategoryParameters).toBeUndefined(); + }); + + it('should expose CategoryBrowser/CategoryParametersReader when constructed with a catalog client', () => { + const catalogClient = { + fetchCategories: jest.fn().mockResolvedValue([{ id: '1', name: 'Cat', parentId: null, leaf: true }]), + fetchCategoryParameters: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked; + const wiredAdapter = new ErliOfferManagerAdapter( + 'conn-1', + ERLI_ADAPTER_KEY, + httpClient, + { period: 2, unit: 'day' }, + undefined, + catalogClient, + ); + + expect(isCategoryBrowser(wiredAdapter)).toBe(true); + expect(isCategoryParametersReader(wiredAdapter)).toBe(true); + }); + + it('should delegate fetchCategories to the catalog client, forwarding parentId', async () => { + const catalogClient = { + fetchCategories: jest.fn().mockResolvedValue([{ id: '1', name: 'Cat', parentId: null, leaf: true }]), + fetchCategoryParameters: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked; + const wiredAdapter = new ErliOfferManagerAdapter( + 'conn-1', + ERLI_ADAPTER_KEY, + httpClient, + undefined, + undefined, + catalogClient, + ); + + const result = await wiredAdapter.fetchCategories?.('parent-1'); + + expect(catalogClient.fetchCategories).toHaveBeenCalledWith('parent-1'); + expect(result).toEqual([{ id: '1', name: 'Cat', parentId: null, leaf: true }]); + }); + + it('should delegate fetchCategoryParameters to the catalog client, adapting the {categoryId} input to a plain id', async () => { + const catalogClient = { + fetchCategories: jest.fn().mockResolvedValue([]), + fetchCategoryParameters: jest.fn().mockResolvedValue([{ id: 'p1', name: 'Param', type: 'string', required: false, multiValue: false }]), + } as unknown as jest.Mocked; + const wiredAdapter = new ErliOfferManagerAdapter( + 'conn-1', + ERLI_ADAPTER_KEY, + httpClient, + undefined, + undefined, + catalogClient, + ); + + const result = await wiredAdapter.fetchCategoryParameters?.({ categoryId: 'cat-1' }); + + expect(catalogClient.fetchCategoryParameters).toHaveBeenCalledWith('cat-1'); + expect(result).toEqual([ + { id: 'p1', name: 'Param', type: 'string', required: false, multiValue: false }, + ]); + }); + }); + describe('createOffer', () => { it("should submit to the seller-keyed product path and return status 'draft' on 202", async () => { const result = await adapter.createOffer(createCmd()); diff --git a/libs/integrations/erli/src/infrastructure/adapters/erli-connection-config-shape-validator.adapter.ts b/libs/integrations/erli/src/infrastructure/adapters/erli-connection-config-shape-validator.adapter.ts index 1f7ed72e4..350a1d49c 100644 --- a/libs/integrations/erli/src/infrastructure/adapters/erli-connection-config-shape-validator.adapter.ts +++ b/libs/integrations/erli/src/infrastructure/adapters/erli-connection-config-shape-validator.adapter.ts @@ -9,6 +9,11 @@ * `ConnectionConfigShapeValidatorRegistryService` at `erli.shopapi.v1`; * `ConnectionService` maps the thrown exception to a 400 at the API boundary. * + * Also validates the optional `allegroEnvironment` selector (#1382/#1383, + * ADR-031) — when present, it must be one of `AllegroCatalogEnvironmentValues` + * (`'sandbox' | 'production'`), the two hosts `AllegroCategoryCatalogClient` + * knows how to resolve. + * * Hand-rolled (no class-validator) — one optional field doesn't justify a DTO * graph, and Erli stays dependency-light. * @@ -21,6 +26,7 @@ import { InvalidConnectionConfigException, } from '@openlinker/core/integrations'; import { ERLI_ALLOWED_BASE_URL_HOSTS, isAllowedErliHost } from '../../domain/policies/erli-base-url.policy'; +import { AllegroCatalogEnvironmentValues } from '../../domain/types/erli-connection.types'; export class ErliConnectionConfigShapeValidatorAdapter implements ConnectionConfigShapeValidatorPort @@ -48,6 +54,8 @@ export class ErliConnectionConfigShapeValidatorAdapter } this.validateDispatchTime(config.defaultDispatchTime, issues); + this.validateAllegroEnvironment(config.allegroEnvironment, issues); + this.validateAllegroCategoryAccessEnabled(config.allegroCategoryAccessEnabled, issues); const callbackBaseUrl = config.callbackBaseUrl; if (callbackBaseUrl !== undefined) { @@ -110,4 +118,43 @@ export class ErliConnectionConfigShapeValidatorAdapter }); } } + + /** + * `allegroEnvironment`, when present, must be exactly `'sandbox'` or + * `'production'` — the two hosts `AllegroCategoryCatalogClient` resolves + * against. Absent is valid (defaults to `'production'` at read time). + */ + private validateAllegroEnvironment(value: unknown, issues: FlatValidationIssue[]): void { + if (value === undefined) { + return; + } + if ( + typeof value !== 'string' || + !(AllegroCatalogEnvironmentValues as readonly string[]).includes(value) + ) { + issues.push({ + path: 'allegroEnvironment', + message: `must be one of: ${AllegroCatalogEnvironmentValues.join(', ')}`, + }); + } + } + + /** + * `allegroCategoryAccessEnabled`, when present, must be a boolean. This is + * the non-secret, FE-visible signal that `allegroClientId`/`allegroClientSecret` + * are both configured on this connection (#1383, ADR-031 "Correction") — + * `connection.supportedCapabilities` cannot serve this purpose since it is a + * static, per-adapterKey manifest value, not computed per connection instance. + */ + private validateAllegroCategoryAccessEnabled(value: unknown, issues: FlatValidationIssue[]): void { + if (value === undefined) { + return; + } + if (typeof value !== 'boolean') { + issues.push({ + path: 'allegroCategoryAccessEnabled', + message: 'must be a boolean when provided', + }); + } + } } diff --git a/libs/integrations/erli/src/infrastructure/adapters/erli-connection-credentials-shape-validator.adapter.ts b/libs/integrations/erli/src/infrastructure/adapters/erli-connection-credentials-shape-validator.adapter.ts index f76018c6d..81c19f284 100644 --- a/libs/integrations/erli/src/infrastructure/adapters/erli-connection-credentials-shape-validator.adapter.ts +++ b/libs/integrations/erli/src/infrastructure/adapters/erli-connection-credentials-shape-validator.adapter.ts @@ -6,6 +6,12 @@ * `ConnectionCredentialsShapeValidatorRegistryService` at `erli.shopapi.v1`; * `ConnectionService` maps the thrown exception to a 400 at the API boundary. * + * Also enforces the optional `allegroClientId`/`allegroClientSecret` pair + * (#1382/#1383, ADR-031) — the Allegro app credentials that enable the + * category-catalog client-credentials flow. The two fields are "both or + * neither": an incomplete pair can never authenticate against Allegro, so it's + * a misconfiguration rather than a valid "catalog browsing disabled" state. + * * Hand-rolled (no class-validator) like the PrestaShop credentials validator — * one required field doesn't justify a DTO graph, and Erli stays * dependency-light. @@ -33,6 +39,37 @@ export class ErliConnectionCredentialsShapeValidatorAdapter ), ); } + const allegroPairError = this.validateAllegroCredentialPair(credentials); + if (allegroPairError) { + return Promise.reject(new InvalidCredentialsShapeException(this.pluginName, allegroPairError)); + } return Promise.resolve(); } + + /** + * `allegroClientId`/`allegroClientSecret` (#1382/#1383, ADR-031) must both be + * present-and-non-empty, or both absent. Returns a message when the pair is + * incomplete or malformed; `null` when it's valid (neither present, or both + * present as non-empty strings). + */ + private validateAllegroCredentialPair(credentials: Record): string | null { + const clientId = credentials.allegroClientId; + const clientSecret = credentials.allegroClientSecret; + const clientIdPresent = clientId !== undefined; + const clientSecretPresent = clientSecret !== undefined; + if (!clientIdPresent && !clientSecretPresent) { + return null; + } + if (clientIdPresent && clientSecretPresent) { + if (!this.isNonEmptyString(clientId) || !this.isNonEmptyString(clientSecret)) { + return '`allegroClientId` and `allegroClientSecret` must both be non-empty strings when provided'; + } + return null; + } + return 'must include both `allegroClientId` and `allegroClientSecret`, or neither'; + } + + private isNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0; + } } diff --git a/libs/integrations/erli/src/infrastructure/adapters/erli-offer-manager.adapter.ts b/libs/integrations/erli/src/infrastructure/adapters/erli-offer-manager.adapter.ts index 95256883b..8522be61d 100644 --- a/libs/integrations/erli/src/infrastructure/adapters/erli-offer-manager.adapter.ts +++ b/libs/integrations/erli/src/infrastructure/adapters/erli-offer-manager.adapter.ts @@ -79,15 +79,34 @@ * Out of scope (own issues, marked seams): master-price → offer propagation (no * core trigger today), offer-status reconciliation #989. * + * Allegro category-catalog browsing (#1383, ADR-031): a connection that + * configured Allegro app credentials (`allegroClientId`/`allegroClientSecret`, + * #1382) gets `fetchCategories`/`fetchCategoryParameters` wired as OPTIONAL + * INSTANCE properties in the constructor, delegating to the shared + * `AllegroCategoryCatalogClient`. This class deliberately does NOT declare + * `CategoryBrowser`/`CategoryParametersReader` in its `implements` clause — + * doing so would make every Erli connection advertise the capability + * regardless of whether Allegro credentials are configured, which would both + * misreport per-connection support and regress the #1367 bulk-wizard + * capability gate (`connection.supportedCapabilities.includes('CategoryBrowser')` + * for connections that never configured Allegro credentials). Runtime callers + * narrow with `isCategoryBrowser`/`isCategoryParametersReader` + * (`typeof adapter.fetchCategories === 'function'`), which correctly reflects + * per-instance wiring instead of a static class capability. See ADR-031 + * "Alternatives considered" for why the static-implements approach was + * rejected. + * * @module libs/integrations/erli/src/infrastructure/adapters * @see {@link OfferManagerPort} */ import { OfferCreateRejectedException, OfferNotFoundOnMarketplaceException, + type CategoryParameter, type CreateOfferCommand, type CreateOfferResult, type CreateOfferValidationError, + type OfferCategory, type OfferCreator, type OfferDescriptionUpdate, type OfferFieldUpdate, @@ -108,6 +127,7 @@ import { ErliApiException } from '../../domain/exceptions/erli-api.exception'; import { ErliConfigException } from '../../domain/exceptions/erli-config.exception'; import { ERLI_PRODUCT_ID_PATTERN } from '../../erli.constants'; import type { ErliDispatchTime } from '../../domain/types/erli-connection.types'; +import type { AllegroCategoryCatalogClient } from '../http/allegro-category-catalog-client'; import type { IErliHttpClient } from '../http/erli-http-client.interface'; import type { ErliExternalAttribute, @@ -180,6 +200,24 @@ export class ErliOfferManagerAdapter return 'allegro'; } + /** + * `CategoryBrowser.fetchCategories` (#1383, ADR-031) — assigned in the + * constructor ONLY when `allegroCategoryCatalog` is provided. Declared as an + * optional instance property (not a class method / `implements` member) so + * `isCategoryBrowser` (`typeof adapter.fetchCategories === 'function'`) + * reflects per-connection Allegro-credential configuration rather than a + * static, connection-independent capability. + */ + fetchCategories?: (parentId?: string) => Promise; + + /** + * `CategoryParametersReader.fetchCategoryParameters` (#1383, ADR-031). Same + * per-instance wiring as {@link fetchCategories} — delegates to the shared + * `AllegroCategoryCatalogClient`, adapting its plain-`categoryId` signature + * to the port's `{ categoryId }` input shape. + */ + fetchCategoryParameters?: (input: { categoryId: string }) => Promise; + constructor( private readonly connectionId: string, private readonly adapterKey: string, @@ -196,7 +234,24 @@ export class ErliOfferManagerAdapter * which case every consult fails open (push stock = pre-#1066 behaviour). */ private readonly cache?: CachePort, - ) {} + /** + * Shared Allegro category-catalog client (#1382/#1383, ADR-031). Provided + * by `ErliAdapterFactory` only when the connection's resolved credentials + * carry BOTH `allegroClientId` and `allegroClientSecret`. When present, + * wires {@link fetchCategories}/{@link fetchCategoryParameters}; when + * absent, both stay `undefined` and this instance offers no category + * browsing at all — exactly the "adapter doesn't implement this + * capability" case callers already handle. + */ + allegroCategoryCatalog?: AllegroCategoryCatalogClient, + ) { + if (allegroCategoryCatalog) { + this.fetchCategories = (parentId?: string): Promise => + allegroCategoryCatalog.fetchCategories(parentId); + this.fetchCategoryParameters = (input: { categoryId: string }): Promise => + allegroCategoryCatalog.fetchCategoryParameters(input.categoryId); + } + } async createOffer(cmd: CreateOfferCommand): Promise { const externalOfferId = this.resolveErliProductId(cmd); 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 index 70832e3af..2aa811720 100644 --- 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 @@ -352,6 +352,106 @@ describe('AllegroCategoryCatalogClient', () => { }); }); + describe('distributed token cache (#1399 review)', () => { + function inMemoryCachePort(): { + get: jest.Mock; + set: jest.Mock; + delete: jest.Mock; + store: Map; + } { + const store = new Map(); + return { + store, + get: jest.fn((key: string) => Promise.resolve(store.has(key) ? store.get(key) : null)), + set: jest.fn((key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(); + }), + delete: jest.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + }; + } + + it('should write the acquired token to the cache keyed by clientId', async () => { + const cache = inMemoryCachePort(); + const cachedClient = new AllegroCategoryCatalogClient(CLIENT_ID, CLIENT_SECRET, 'sandbox', cache); + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'app-token-1', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + + await cachedClient.fetchCategories(); + + expect(cache.set).toHaveBeenCalledWith( + `erli:allegro-category-token:${CLIENT_ID}`, + expect.objectContaining({ accessToken: 'app-token-1' }), + expect.any(Number) + ); + }); + + it('should reuse a token found in the cache instead of re-acquiring, across separate client instances', async () => { + const cache = inMemoryCachePort(); + const first = new AllegroCategoryCatalogClient(CLIENT_ID, CLIENT_SECRET, 'sandbox', cache); + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'shared-token', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + await first.fetchCategories(); + + // A fresh instance (e.g. built by a later, unrelated `createAdapters` + // call) sharing the same CachePort must find the token in `cache` + // rather than paying another OAuth round-trip. + const second = new AllegroCategoryCatalogClient(CLIENT_ID, CLIENT_SECRET, 'sandbox', cache); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + await second.fetchCategories(); + + const tokenRequests = recordedCalls(fetchMock).filter(([url]) => url.includes('/auth/oauth/token')); + expect(tokenRequests).toHaveLength(1); + const secondCategoriesCall = recordedCalls(fetchMock)[2]; + expect(secondCategoriesCall[1]?.headers?.Authorization).toBe('Bearer shared-token'); + }); + + it('should re-acquire when the cached entry is expired, even if present in the cache', async () => { + const cache = inMemoryCachePort(); + const nowSpy = jest.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_000_000); + cache.store.set(`erli:allegro-category-token:${CLIENT_ID}`, { + accessToken: 'stale-shared-token', + expiresAt: 1_000_000 - 1, + }); + const cachedClient = new AllegroCategoryCatalogClient(CLIENT_ID, CLIENT_SECRET, 'sandbox', cache); + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'fresh-token', expires_in: 3600, token_type: 'bearer' }) + ) + .mockResolvedValueOnce(jsonResponse(200, { categories: [] })); + + await cachedClient.fetchCategories(); + + const tokenRequests = recordedCalls(fetchMock).filter(([url]) => url.includes('/auth/oauth/token')); + expect(tokenRequests).toHaveLength(1); + nowSpy.mockRestore(); + }); + + it('should behave exactly as before (in-memory only) when no cache is supplied', async () => { + // `client` (from the outer beforeEach) is constructed without a cache — + // backward-compat: no CachePort dependency introduced for existing callers. + 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); + }); + }); + 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 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 index 886e2184a..49b58b42e 100644 --- a/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.ts +++ b/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.ts @@ -5,7 +5,7 @@ * 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). + * ADR-031). * * Deliberately does NOT import anything from `@openlinker/integrations-allegro` * — plugin packages are architecturally independent (ADR-003), and Erli must @@ -18,12 +18,23 @@ * 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 + * truth) — duplicated here per ADR-031 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`. * + * Token cache (#1399 review): the in-memory `cached` field only helps within + * one instance's lifetime, and `ErliAdapterFactory` builds a fresh instance + * per `getCapabilityAdapter` call — with no adapter-instance caching anywhere + * in the resolution seam, every `fetchCategoryParameters` call would otherwise + * pay a full `client_credentials` OAuth round-trip. When the optional `cache` + * (`host.cache`, `CachePort`) is supplied, the acquired token is additionally + * persisted there keyed by `clientId` (an Allegro app's client-credentials + * token isn't connection-scoped — the same app can back multiple Erli + * connections) with a TTL matching the token's own remaining lifetime, so it + * survives across per-request client instances and across processes. + * * @module libs/integrations/erli/src/infrastructure/http */ import type { @@ -31,6 +42,7 @@ import type { CategoryParameterDictionaryEntry, OfferCategory, } from '@openlinker/core/listings'; +import type { CachePort } from '@openlinker/shared'; 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'; @@ -75,13 +87,24 @@ export class AllegroCategoryCatalogClient { constructor( private readonly clientId: string, private readonly clientSecret: string, - environment: AllegroCatalogEnvironment + environment: AllegroCatalogEnvironment, + private readonly cache?: CachePort ) { 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; } + /** + * Namespaced by `clientId` (never `connectionId`) — the cached value is an + * Allegro app-level `client_credentials` token, not a per-connection one. + * `encodeURIComponent` backstops a stray `:` from blurring the namespace + * separator, mirroring `ErliOfferManagerAdapter.frozenStockCacheKey`. + */ + private get tokenCacheKey(): string { + return `erli:allegro-category-token:${encodeURIComponent(this.clientId)}`; + } + /** * `CategoryBrowser.fetchCategories` — `GET /sale/categories?parent.id=`. * Mirrors `AllegroOfferManagerAdapter.fetchCategories`'s field mapping. @@ -116,14 +139,23 @@ export class AllegroCategoryCatalogClient { /** * 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`. + * + * Checks the in-memory `cached` field first (free, no I/O), then falls back + * to the distributed `cache` (if supplied) before paying the OAuth + * round-trip — a fresh token acquired here is written to both, so the next + * per-request instance for this `clientId` finds it in `cache` instead of + * re-acquiring. */ 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.isFresh(this.cached)) { + return this.cached!.accessToken; + } + if (this.cache) { + const shared = await this.cache.get(this.tokenCacheKey); + if (this.isFresh(shared ?? undefined)) { + this.cached = shared!; + return this.cached.accessToken; + } } if (!this.inFlightToken) { this.inFlightToken = this.acquireToken().finally(() => { @@ -131,9 +163,22 @@ export class AllegroCategoryCatalogClient { }); } this.cached = await this.inFlightToken; + if (this.cache && this.cached.expiresAt !== undefined) { + const ttlSec = Math.max(1, Math.floor((this.cached.expiresAt - Date.now()) / 1000)); + await this.cache.set(this.tokenCacheKey, this.cached, ttlSec); + } return this.cached.accessToken; } + /** A token is usable if it has a known expiry more than the refresh window away. */ + private isFresh(token: CachedToken | undefined): boolean { + return ( + token !== undefined && + token.expiresAt !== undefined && + Date.now() < token.expiresAt - TOKEN_REFRESH_WINDOW_MS + ); + } + private async acquireToken(): Promise { const tokenUrl = `${this.webBaseUrl}/auth/oauth/token`; const basicAuth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); @@ -212,7 +257,7 @@ export class AllegroCategoryCatalogClient { * 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). + * (kept in sync manually per ADR-031'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