diff --git a/apps/api/src/integrations/application/services/connection.service.spec.ts b/apps/api/src/integrations/application/services/connection.service.spec.ts index b593ea47f..7a6675a2e 100644 --- a/apps/api/src/integrations/application/services/connection.service.spec.ts +++ b/apps/api/src/integrations/application/services/connection.service.spec.ts @@ -39,7 +39,11 @@ import { CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN, ConnectionCredentialsShapeValidatorRegistryService, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, + ConnectionCredentialsRewriterRegistryService, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + ConnectionCredentialsRewriteException, } from '@openlinker/core/integrations'; +import type { ConnectionCredentialsRewriterPort } from '@openlinker/core/integrations'; import { AllegroConnectionConfigShapeValidatorAdapter } from '@openlinker/integrations-allegro'; import { PrestashopConnectionConfigShapeValidatorAdapter, @@ -61,6 +65,7 @@ describe('ConnectionService', () => { let mockWebhookProvisioner: jest.Mocked; let configValidatorRegistry: ConnectionConfigShapeValidatorRegistryService; let credentialsValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService; + let credentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService; const mockConnection = new Connection( 'connection-123', @@ -128,6 +133,14 @@ describe('ConnectionService', () => { }) ), update: jest.fn(), + getByRef: jest.fn().mockResolvedValue({ + id: 'cred-row-1', + ref: 'cred-ref-1', + platformType: 'prestashop', + credentialsJson: {}, + createdAt: new Date(), + updatedAt: new Date(), + }), delete: jest.fn().mockResolvedValue(true), } as unknown as jest.Mocked; @@ -163,6 +176,12 @@ describe('ConnectionService', () => { new PrestashopConnectionCredentialsShapeValidatorAdapter() ); + // Credentials-rewriter registry (#1387, ADR-031). Empty by default so + // `updateCredentials` exercises the no-op passthrough for every existing + // test; individual tests register a stub rewriter to exercise the + // delegation path. + credentialsRewriterRegistry = new ConnectionCredentialsRewriterRegistryService(); + const mockCredentialsResolver: CredentialsResolverPort = { get: jest.fn(), } as unknown as CredentialsResolverPort; @@ -184,6 +203,10 @@ describe('ConnectionService', () => { provide: CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, useValue: credentialsValidatorRegistry, }, + { + provide: CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + useValue: credentialsRewriterRegistry, + }, { provide: CREDENTIALS_RESOLVER_TOKEN, useValue: mockCredentialsResolver }, ], }).compile(); @@ -815,6 +838,36 @@ describe('ConnectionService', () => { }); }); + it('should merge onto existing stored fields instead of replacing the whole blob', async () => { + const dbConnection = new Connection( + 'connection-123', + 'prestashop', + 'Test Connection', + 'active', + {}, + 'db:cred-ref-1', + new Date(), + new Date(), + undefined, + ['ProductMaster'] + ); + connectionPort.get.mockResolvedValue(dbConnection); + credentials.getByRef.mockResolvedValue({ + id: 'cred-row-1', + ref: 'cred-ref-1', + platformType: 'prestashop', + credentialsJson: { webserviceApiKey: 'OLD', otherField: 'keep-me' }, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' }); + + expect(credentials.update).toHaveBeenCalledWith('cred-ref-1', { + credentialsJson: { webserviceApiKey: 'NEW', otherField: 'keep-me' }, + }); + }); + it('should reject rotation on non-db-backed connection', async () => { const legacy = new Connection( 'connection-123', @@ -835,6 +888,80 @@ describe('ConnectionService', () => { ).rejects.toThrow(/does not have a db-backed/); expect(credentials.update).not.toHaveBeenCalled(); }); + + describe('credentials rewriter dispatch (#1387, ADR-031)', () => { + // ConnectionService has zero platform-specific knowledge of what a + // rewriter does (that logic — e.g. Erli's Allegro-credentials-reuse + // resolution — lives behind `ConnectionCredentialsRewriterPort` in the + // owning plugin package and is unit-tested there). These tests only + // pin the generic dispatch contract: no-op passthrough when nothing is + // registered for the adapterKey, and delegation + error-mapping when a + // rewriter is registered. + const dbConnection = new Connection( + 'connection-123', + 'prestashop', + 'Test Connection', + 'active', + {}, + 'db:cred-ref-1', + new Date(), + new Date(), + undefined, + ['ProductMaster'] + ); + + beforeEach(() => { + connectionPort.get.mockResolvedValue(dbConnection); + credentials.getByRef.mockResolvedValue({ + id: 'cred-row-1', + ref: 'cred-ref-1', + platformType: 'prestashop', + credentialsJson: { webserviceApiKey: 'EXISTING' }, + createdAt: new Date(), + updatedAt: new Date(), + }); + }); + + it('should pass the credentials through unchanged when no rewriter is registered for the adapterKey', async () => { + await service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' }); + + expect(credentials.update).toHaveBeenCalledWith('cred-ref-1', { + credentialsJson: { webserviceApiKey: 'NEW' }, + }); + }); + + it('should delegate to the registered rewriter and persist its returned payload', async () => { + const stubRewriter: jest.Mocked = { + rewrite: jest + .fn() + .mockResolvedValue({ webserviceApiKey: 'REWRITTEN', extraField: 'added-by-rewriter' }), + }; + credentialsRewriterRegistry.register('prestashop.webservice.v1', stubRewriter); + + await service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' }); + + expect(stubRewriter.rewrite).toHaveBeenCalledWith({ webserviceApiKey: 'NEW' }); + expect(credentials.update).toHaveBeenCalledWith('cred-ref-1', { + credentialsJson: { webserviceApiKey: 'REWRITTEN', extraField: 'added-by-rewriter' }, + }); + }); + + it('should map a ConnectionCredentialsRewriteException from the rewriter to BadRequestException', async () => { + const stubRewriter: jest.Mocked = { + rewrite: jest + .fn() + .mockRejectedValue( + new ConnectionCredentialsRewriteException('Stub', 'source connection is invalid') + ), + }; + credentialsRewriterRegistry.register('prestashop.webservice.v1', stubRewriter); + + await expect( + service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' }) + ).rejects.toThrow(BadRequestException); + expect(credentials.update).not.toHaveBeenCalled(); + }); + }); }); describe('testConnection', () => { diff --git a/apps/api/src/integrations/application/services/connection.service.ts b/apps/api/src/integrations/application/services/connection.service.ts index 103c8209f..15c2dedf0 100644 --- a/apps/api/src/integrations/application/services/connection.service.ts +++ b/apps/api/src/integrations/application/services/connection.service.ts @@ -43,8 +43,11 @@ import { CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN, ConnectionCredentialsShapeValidatorRegistryService, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, + ConnectionCredentialsRewriterRegistryService, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, InvalidConnectionConfigException, InvalidCredentialsShapeException, + ConnectionCredentialsRewriteException, } from '@openlinker/core/integrations'; import type { SyncJobRequest } from '@openlinker/core/sync'; import { JobEnqueuePort, JOB_ENQUEUE_TOKEN } from '@openlinker/core/sync'; @@ -72,6 +75,8 @@ export class ConnectionService implements IConnectionService { private readonly connectionConfigShapeValidatorRegistry: ConnectionConfigShapeValidatorRegistryService, @Inject(CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN) private readonly connectionCredentialsShapeValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService, + @Inject(CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN) + private readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService, @Inject(CREDENTIALS_RESOLVER_TOKEN) private readonly credentialsResolver: CredentialsResolverPort ) {} @@ -118,6 +123,32 @@ export class ConnectionService implements IConnectionService { } } + /** + * Run the plugin's credentials rewriter if one is registered for this + * adapterKey (#1387, ADR-031). A rewriter transforms the raw credentials + * payload BEFORE it is merged onto the existing stored blob and shape- + * validated — e.g. Erli resolves `reuseAllegroConnectionId` into a concrete + * `allegroClientId`/`allegroClientSecret` pair fetched server-side, so the + * raw Allegro `clientSecret` never round-trips through this HTTP layer. + * This service has zero platform-specific knowledge of what a rewriter + * does — it's a no-op passthrough when nothing is registered. + */ + private async rewriteCredentials( + adapterKey: string, + credentials: Record + ): Promise> { + const rewriter = this.connectionCredentialsRewriterRegistry.get(adapterKey); + if (!rewriter) return credentials; + try { + return await rewriter.rewrite(credentials); + } catch (error) { + if (error instanceof ConnectionCredentialsRewriteException) { + throw new BadRequestException(error.message); + } + throw error; + } + } + async installWebhooks( connectionId: string, actorUserId?: string @@ -416,9 +447,17 @@ export class ConnectionService implements IConnectionService { platformType: connection.platformType, adapterKey: connection.adapterKey, }); - await this.validateCredentialsShape(metadata.adapterKey, credentials); + const resolvedCredentials = await this.rewriteCredentials(metadata.adapterKey, credentials); const ref = connection.credentialsRef.slice('db:'.length); - await this.credentials.update(ref, { credentialsJson: credentials }); + // Merge onto the existing stored credentials rather than replacing the + // whole blob: callers only send the fields they actually changed (e.g. + // rotating just `apiKey`), and a full replace would silently delete any + // other previously-stored field (e.g. Erli's optional Allegro + // `allegroClientId`/`allegroClientSecret` pair, #1401 review). + const existing = await this.credentials.getByRef(ref); + const mergedCredentials = { ...existing.credentialsJson, ...resolvedCredentials }; + await this.validateCredentialsShape(metadata.adapterKey, mergedCredentials); + await this.credentials.update(ref, { credentialsJson: mergedCredentials }); this.logger.log(`Rotated credentials for connection ${connectionId}`); } 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/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts b/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts index b7f913e56..a5c3ca2e8 100644 --- a/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts +++ b/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts @@ -53,8 +53,13 @@ function readString(params: Record | undefined, key: string): s * (product). The form-state map is keyed by parameter id alone — re-submission * re-derives the section split from the freshly-loaded category-parameters * metadata, so the section distinction is not preserved on the form side. + * + * Exported for reuse by the Erli retry mapper (#1384) — both platforms + * persist the same neutral `overrides.parameters` wire shape. */ -function readParameters(overrides: CreateOfferOverrides | undefined): CategoryParameterFormValues { +export function readParameters( + overrides: CreateOfferOverrides | undefined, +): CategoryParameterFormValues { const out: CategoryParameterFormValues = {}; appendWireParameters(out, overrides?.parameters); // Transitional fallback for snapshots persisted before #1071. diff --git a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts index e3294b746..08fd8b8d6 100644 --- a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts +++ b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts @@ -55,6 +55,24 @@ describe('createErliOfferRequestToFormValues', () => { expect(values.dispatchPeriod).toBe(2); expect(values.dispatchUnit).toBe('day'); }); + + it('restores category-parameter values from the wire snapshot on retry', () => { + const values = createErliOfferRequestToFormValues( + snapshot({ + overrides: { + title: 'With parameters', + categoryId: '12345', + parameters: [ + { id: 'p1', values: ['Red'], section: 'offer' }, + { id: 'p2', valuesIds: ['v1', 'v2'], section: 'offer' }, + ], + }, + }), + FALLBACK, + ); + + expect(values.parameters).toEqual({ p1: 'Red', p2: ['v1', 'v2'] }); + }); }); describe('readErliOfferRequestPrefill', () => { diff --git a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts index 7d06ef493..3d75bfc24 100644 --- a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts +++ b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts @@ -26,6 +26,7 @@ import { SUPPORTED_OFFER_CREATION_REQUEST_SCHEMA_VERSION, type CreateOfferRequest, } from '../../api/listings.types'; +import { readParameters } from '../create-offer-request-to-form-values'; import type { ErliCreateOfferValues } from './erli-create-offer.schema'; import { isValidDispatch, type ErliDispatchTimeParam } from './erli-offer-fields.schema'; @@ -62,6 +63,10 @@ export function createErliOfferRequestToFormValues( publishImmediately: request.publishImmediately, dispatchPeriod: dispatch.period, dispatchUnit: dispatch.unit, + // Reuses the Allegro retry mapper's heuristic (#1384) — both platforms + // persist the same neutral `overrides.parameters` wire shape, so a + // retried offer re-opens with its previously entered parameter values. + parameters: readParameters(overrides), }; } diff --git a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx index 059e7ff68..eeb82c645 100644 --- a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx +++ b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx @@ -28,6 +28,15 @@ const erliConnection: Connection = { updatedAt: '2026-01-01T00:00:00Z', }; +// #1384 — a per-connection-instance signal (`config.allegroCategoryAccessEnabled`), +// deliberately NOT reflected in `supportedCapabilities` (static per-adapterKey, +// see ADR-031 "Correction") — both connections keep the same `supportedCapabilities`. +const erliConnectionWithCategoryAccess: Connection = { + ...erliConnection, + id: 'conn_erli_2', + config: { ...erliConnection.config, allegroCategoryAccessEnabled: true }, +}; + function productWith(images: string[] | null): Product { return { id: 'ol_product_abc', @@ -205,4 +214,140 @@ describe('ErliCreateOfferWizard', () => { dispatchTime: { period: 5, unit: 'hour' }, }); }); + + // #1384 — capability-conditional category/parameters steps. + describe('Allegro category access (#1384)', () => { + it('keeps the plain-text category field and shows the fallback hint when access is not configured', async () => { + renderWithProviders( + , + { apiClient: mocks(productWith(['https://cdn.example.com/a.jpg'])) }, + ); + + await pickVariantAndAdvance(); + + expect(await screen.findByPlaceholderText(/e\.g\. 12345/i)).toBeInTheDocument(); + expect( + screen.getByText(/add allegro category browsing to this connection/i), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /configure category browsing/i })).toHaveAttribute( + 'href', + `/connections/${erliConnection.id}/edit`, + ); + // Only the 3-step shape — no "Category" / "Category parameters" steps. + expect(screen.queryByText('Category parameters')).not.toBeInTheDocument(); + }); + + it('renders the Category and Category-parameters steps, blocks on a required parameter, and submits overrides.parameters', async () => { + const mockApi = mocks(productWith(['https://cdn.example.com/a.jpg']), { + connections: { list: vi.fn().mockResolvedValue([erliConnectionWithCategoryAccess]) }, + listings: { + createOffer: vi + .fn() + .mockResolvedValue({ jobId: 'job-1', offerCreationRecordId: 'rec-1' }), + resolveCategory: vi.fn().mockResolvedValue({ allegroCategoryId: null, method: 'manual' }), + getCategoryParameters: vi.fn().mockResolvedValue({ + parameters: [ + { + id: 'p_stan', + name: 'Stan', + type: 'dictionary', + required: true, + section: 'offer', + restrictions: {}, + dictionary: [{ id: 'nowy', value: 'Nowy' }], + }, + ], + }), + }, + mappings: { + getAllegroCategories: vi + .fn() + .mockResolvedValue([{ id: '12345', name: 'Test Category', parentId: null, leaf: true }]), + }, + }); + renderWithProviders( + , + { apiClient: mockApi }, + ); + + await pickVariantAndAdvance(); + // Offer-details step: no plain-text category field or hint anymore — + // it moved to its own step. + fireEvent.change(await screen.findByLabelText(/^price \(PLN\)$/i), { + target: { value: '99.99' }, + }); + expect(screen.queryByPlaceholderText(/e\.g\. 12345/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category step — pick the leaf via the reused CategoryPicker. + const selectButton = await screen.findByRole('button', { name: /^select$/i }); + fireEvent.click(selectButton); + await waitFor(() => + expect(screen.getByRole('button', { name: /^selected$/i })).toBeInTheDocument(), + ); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category-parameters step — required "Stan" field blocks Next until filled. + await screen.findByText('Stan'); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + expect(await screen.findByText(/stan is required/i)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Stan'), { target: { value: 'nowy' } }); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + fireEvent.click(await screen.findByRole('button', { name: /create offer/i })); + + const createOffer = vi.mocked(mockApi.listings.createOffer); + await waitFor(() => expect(createOffer).toHaveBeenCalledTimes(1)); + const [, request] = createOffer.mock.calls[0] as [string, CreateOfferRequest]; + expect(request.overrides?.categoryId).toBe('12345'); + expect(request.overrides?.parameters).toEqual([ + { id: 'p_stan', valuesIds: ['nowy'], section: 'offer' }, + ]); + }); + + it('blocks Next on the Category step until a category is selected (#1401 review)', async () => { + const mockApi = mocks(productWith(['https://cdn.example.com/a.jpg']), { + connections: { list: vi.fn().mockResolvedValue([erliConnectionWithCategoryAccess]) }, + listings: { + createOffer: vi + .fn() + .mockResolvedValue({ jobId: 'job-1', offerCreationRecordId: 'rec-1' }), + resolveCategory: vi.fn().mockResolvedValue({ allegroCategoryId: null, method: 'manual' }), + getCategoryParameters: vi.fn().mockResolvedValue({ parameters: [] }), + }, + mappings: { + getAllegroCategories: vi + .fn() + .mockResolvedValue([{ id: '12345', name: 'Test Category', parentId: null, leaf: true }]), + }, + }); + renderWithProviders( + , + { apiClient: mockApi }, + ); + + await pickVariantAndAdvance(); + fireEvent.change(await screen.findByLabelText(/^price \(PLN\)$/i), { + target: { value: '99.99' }, + }); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category step — click Next without touching the CategoryPicker at all. + await screen.findByText('Category'); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + expect(await screen.findByText(/select a category to continue/i)).toBeInTheDocument(); + // Still on the Category step — Category-parameters never rendered. + expect(screen.queryByText(/no additional parameters required/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx index fe56c2199..b5b73dfdd 100644 --- a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx +++ b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx @@ -31,8 +31,9 @@ * @module features/listings/components/erli */ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { Controller, FormProvider, useForm, type FieldPath } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Link } from 'react-router-dom'; import { Alert } from '../../../../shared/ui/alert'; import { Button } from '../../../../shared/ui/button'; @@ -49,8 +50,21 @@ import { useProductQuery, useProductsQuery, useVariantQuery } from '../../../pro import type { Product, ProductVariant } from '../../../products'; import { useCreateOfferMutation } from '../../hooks/use-create-offer-mutation'; import { useResolveCategoryQuery } from '../../hooks/use-resolve-category-query'; -import type { CreateOfferRequest } from '../../api/listings.types'; -import { erliCreateOfferSchema, type ErliCreateOfferValues } from './erli-create-offer.schema'; +import { useCategoryParametersQuery } from '../../hooks/use-category-parameters-query'; +import { CategoryPicker } from '../CategoryPicker'; +import { CategoryParametersStep } from '../category-parameters-step'; +import { buildParametersZodSchema } from '../build-parameters-zod-schema'; +import { + MissingCategoryParameterSectionError, + categoryParametersToOfferParameters, +} from '../category-parameters-to-offer-parameters'; +import type { CategoryParameterFormValues } from '../category-parameter-form.types'; +import type { CreateOfferRequest, OfferParameter } from '../../api/listings.types'; +import { + erliCreateOfferSchema, + type ErliCreateOfferSubmission, + type ErliCreateOfferValues, +} from './erli-create-offer.schema'; import { ErliDispatchTimeField } from './erli-dispatch-time-field'; import { formatDispatch, @@ -59,10 +73,38 @@ import { } from './erli-offer-fields.schema'; import { readErliOfferRequestPrefill } from './create-erli-offer-request-to-form-values'; -const ERLI_STEP_LABELS = ['Variant', 'Offer details', 'Review'] as const; +/** + * Step labels — capability-conditional (#1384). Erli borrows Allegro's + * category/attribute taxonomy (ADR-023, ADR-031); when the connection has + * Allegro app credentials configured (`config.allegroCategoryAccessEnabled`, + * a per-connection-instance-visible flag — NOT `supportedCapabilities`, + * which is static per-adapterKey and cannot reflect this, see ADR-031 + * "Correction"), the wizard grows a dedicated Category step (CategoryPicker) + * and a Category-parameters step, mirroring `AllegroCreateOfferWizard`. + * Otherwise it keeps today's 3-step shape with the plain-text category field + * folded into "Offer details". + */ +const ERLI_STEP_LABELS_BASIC = ['Variant', 'Offer details', 'Review'] as const; +const ERLI_STEP_LABELS_WITH_CATEGORY = [ + 'Variant', + 'Offer details', + 'Category', + 'Category parameters', + 'Review', +] as const; const VARIANT_SEARCH_DEBOUNCE_MS = 300; const VARIANT_PICKER_PAGE_SIZE = 20; +/** + * RHF cannot infer the dynamic `parameters.{paramId}` path from + * `ErliCreateOfferValues` (the `parameters` slice is `z.record(z.unknown())` + * by design — per-field shapes come from the runtime `CategoryParameter` + * list). Mirrors `AllegroCreateOfferWizard`'s `parametersFieldPath` helper. + */ +function parametersFieldPath(paramId: string): FieldPath { + return `parameters.${paramId}` as FieldPath; +} + function variantLabel(product: Product, variant: ProductVariant): string { const attrs = variant.attributes ? Object.values(variant.attributes).join(' · ') : ''; if (attrs) return `${product.name} — ${attrs}`; @@ -106,7 +148,18 @@ export function ErliCreateOfferWizard({ // Shared, declared-once Erli validator (image gate). Resolved via usePlatform. const erliValidation = usePlatform(connection.platformType)?.offerValidation; - const form = useForm({ + // #1384 — per-connection-instance signal (NOT `connection.supportedCapabilities`, + // which is static per-adapterKey — see ADR-031 "Correction"). Read directly off + // the connection's already-returned `config`, no new query needed. + const allegroCategoryAccessEnabled = connection.config.allegroCategoryAccessEnabled === true; + const stepLabels = allegroCategoryAccessEnabled + ? ERLI_STEP_LABELS_WITH_CATEGORY + : ERLI_STEP_LABELS_BASIC; + const categoryStepIndex = allegroCategoryAccessEnabled ? 2 : null; + const categoryParametersStepIndex = allegroCategoryAccessEnabled ? 3 : null; + const reviewStepIndex = stepLabels.length - 1; + + const form = useForm({ defaultValues: prefill ?? { internalVariantId: defaultVariantId ?? '', variantLabel: '', @@ -118,6 +171,7 @@ export function ErliCreateOfferWizard({ publishImmediately: false, dispatchPeriod: dispatchDefault.period, dispatchUnit: dispatchDefault.unit, + parameters: {}, }, resolver: zodResolver(erliCreateOfferSchema), mode: 'onBlur', @@ -170,6 +224,35 @@ export function ErliCreateOfferWizard({ } }, [categoryQuery.data, form]); + // #1384 — category-parameter schema, only fetched once a category is + // selected AND the connection has Allegro category access configured. + // Same hook Allegro's wizard uses (`useCategoryParametersQuery`) — no new + // hook needed, per the plan. + const currentCategoryId = form.watch('categoryId'); + const categoryParametersQuery = useCategoryParametersQuery( + allegroCategoryAccessEnabled ? connection.id : undefined, + allegroCategoryAccessEnabled ? currentCategoryId || undefined : undefined, + ); + const categoryParameters = useMemo( + () => categoryParametersQuery.data ?? [], + [categoryParametersQuery.data], + ); + // Clear the parameters slice whenever the chosen category changes — the + // shape is category-specific so prior values would never be valid under a + // new schema (mirrors `AllegroCreateOfferWizard`). + const lastCategoryIdRef = useRef(currentCategoryId); + useEffect(() => { + if (lastCategoryIdRef.current && lastCategoryIdRef.current !== currentCategoryId) { + form.setValue('parameters', {}, { shouldDirty: false, shouldValidate: false }); + form.clearErrors('parameters'); + } + lastCategoryIdRef.current = currentCategoryId; + }, [currentCategoryId, form]); + // #423 — surfaces `MissingCategoryParameterSectionError` (stale cache + // predating #417) with an actionable "reload" message, mirroring Allegro's + // wizard. + const [staleSchemaError, setStaleSchemaError] = useState(null); + useEffect(() => { function handleBeforeUnload(event: BeforeUnloadEvent): void { if (!form.formState.isDirty) return; @@ -187,10 +270,10 @@ export function ErliCreateOfferWizard({ () => erliValidation?.validateRow({ imageCount: imageUrls.length, - needsProductParameters: false, + needsProductParameters: allegroCategoryAccessEnabled, willLinkProductCard: false, }) ?? [], - [erliValidation, imageUrls.length], + [erliValidation, imageUrls.length, allegroCategoryAccessEnabled], ); const hasImageBlocker = pickedProduct !== null && imageBlockers.length > 0; @@ -207,19 +290,59 @@ export function ErliCreateOfferWizard({ setPickedVariantEan(variant.ean ?? variant.gtin ?? null); } - const STEP_FIELDS: ReadonlyArray> = [ - ['internalVariantId'], - ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], - [], - ]; + const STEP_FIELDS: ReadonlyArray> = allegroCategoryAccessEnabled + ? [ + ['internalVariantId'], + ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], + [], // Category — CategoryPicker, no static-field trigger + [], // Category parameters — validated dynamically below + [], + ] + : [['internalVariantId'], ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], []]; async function goNext(): Promise { const fields = STEP_FIELDS[stepIndex] ?? []; const valid = fields.length === 0 ? true : await form.trigger(fields); if (!valid) return; if (stepIndex === 1 && hasImageBlocker) return; // can't advance with a missing image + + // #1401 review — Category is a static `[]` STEP_FIELDS entry (CategoryPicker + // has no static-field trigger), so `categoryId` must be enforced manually here, + // mirroring `AllegroCreateOfferWizard`'s required `categoryId` field. Without + // this an operator can click through the Category step without picking one. + if (allegroCategoryAccessEnabled && stepIndex === categoryStepIndex) { + if (!form.getValues('categoryId')) { + form.setError('categoryId', { type: 'manual', message: 'Select a category to continue.' }); + return; + } + form.clearErrors('categoryId'); + } + + // #1384 — dynamic per-category Zod validation, mirrors + // `AllegroCreateOfferWizard`'s Step-3 gate. Skipped when the category has + // no parameters or the schema is still loading. + if ( + allegroCategoryAccessEnabled && + stepIndex === categoryParametersStepIndex && + categoryParameters.length > 0 + ) { + const paramValues = + (form.getValues('parameters') as CategoryParameterFormValues | undefined) ?? {}; + const result = buildParametersZodSchema(categoryParameters).safeParse(paramValues); + if (!result.success) { + form.clearErrors('parameters'); + for (const issue of result.error.issues) { + const paramId = String(issue.path[0] ?? ''); + if (paramId === '') continue; + form.setError(parametersFieldPath(paramId), { type: 'manual', message: issue.message }); + } + return; + } + form.clearErrors('parameters'); + } + setCompletedSteps((prev) => new Set(prev).add(stepIndex)); - setStepIndex((i) => Math.min(i + 1, ERLI_STEP_LABELS.length - 1)); + setStepIndex((i) => Math.min(i + 1, stepLabels.length - 1)); } function goBack(): void { @@ -236,6 +359,28 @@ export function ErliCreateOfferWizard({ // the `defaultVariantId` path where no product was interactively picked. if (imageBlockers.length > 0) return; + setStaleSchemaError(null); // clear from any prior submit + + // #1384 — serialize the Step-4 category-parameter values into the + // neutral `OfferParameter[]` shape, exactly like Allegro's wizard. + // `OfferBuilderService.buildOfferParameters` merges `overrides.parameters` + // server-side unchanged — no BE change needed (confirmed in ADR-031's plan). + let parameters: OfferParameter[] = []; + if (allegroCategoryAccessEnabled) { + try { + parameters = categoryParametersToOfferParameters( + (submitted.parameters as CategoryParameterFormValues | undefined) ?? {}, + categoryParameters, + ); + } catch (error) { + if (error instanceof MissingCategoryParameterSectionError) { + setStaleSchemaError(error.parameterName); + return; + } + throw error; + } + } + const request: CreateOfferRequest = { internalVariantId: submitted.internalVariantId, stock: submitted.stock, @@ -246,6 +391,7 @@ export function ErliCreateOfferWizard({ ...(submitted.categoryId ? { categoryId: submitted.categoryId } : {}), description: submitted.description ? submitted.description : null, imageUrls, + ...(parameters.length > 0 ? { parameters } : {}), platformParams: { dispatchTime: { period: submitted.dispatchPeriod, unit: submitted.dispatchUnit }, }, @@ -291,7 +437,7 @@ export function ErliCreateOfferWizard({ @@ -304,6 +450,22 @@ export function ErliCreateOfferWizard({ {mutation.error.message} ) : null} + {staleSchemaError !== null ? ( + +

+ Category parameter {staleSchemaError} is missing data that was added + in a recent update. Please reload this page to refetch the latest category schema. +

+
+ + +
+
+ ) : null}
- - - + {!allegroCategoryAccessEnabled ? ( + <> + + + +

+ Add Allegro category browsing to this connection to pick from a list instead + of typing a raw category id. Configure category browsing. +

+ + ) : null}
) : null} - {stepIndex === 2 ? ( + {allegroCategoryAccessEnabled && stepIndex === categoryStepIndex ? ( +
+ + Category + + ( + + )} + /> +

+ Browse the Allegro-borrowed category tree (ADR-023/ADR-031) and pick a leaf + category. +

+ {form.formState.errors.categoryId?.message ? ( + + ) : null} +
+ ) : null} + + {allegroCategoryAccessEnabled && stepIndex === categoryParametersStepIndex ? ( + categoryParametersQuery.isLoading ? ( +

+ Loading category parameters… +

+ ) : categoryParametersQuery.error ? ( + + {categoryParametersQuery.error.message} + + + ) : categoryParameters.length === 0 ? ( +

No additional parameters required for this category.

+ ) : ( + + ) + ) : null} + + {stepIndex === reviewStepIndex ? (
Variant
{values.variantLabel || values.internalVariantId}
@@ -499,7 +723,7 @@ export function ErliCreateOfferWizard({ ← Back ) : null} - {stepIndex < ERLI_STEP_LABELS.length - 1 ? ( + {stepIndex < stepLabels.length - 1 ? ( +
+ + Stored encrypted. You won't see it again after saving. + + + + ) : null} + + ) : null} +
-
) : ( - )} diff --git a/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md b/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md index 10228a23c..89a40e3e1 100644 --- a/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md +++ b/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md @@ -18,6 +18,8 @@ Erli owns its own small Allegro client-credentials HTTP client (`AllegroCategory **Correction (found during #1383 implementation)**: the connection-list DTO's `supportedCapabilities` field is populated from the static, per-`adapterKey` `AdapterMetadata.supportedCapabilities` declared at plugin registration — it is **not** computed by resolving each connection's live adapter instance and running the `is*` guards. It therefore cannot differ between two Erli connections regardless of whether either has Allegro app credentials configured, and the bulk-wizard's `connection.supportedCapabilities.includes('CategoryBrowser')` gate (#1367) is consequently **unaffected** by this feature (it stays `false` for Erli either way — a pre-existing bulk-wizard limitation, unchanged, and out of scope here per the plan's non-goals). The frontend single-offer wizard therefore needs its own, per-connection-instance-visible signal: a new non-secret `ErliConnectionConfig.allegroCategoryAccessEnabled?: boolean` field, written/cleared by the backend in the same operation that writes/clears `allegroClientId`/`allegroClientSecret`, and read directly by the FE from the connection's `config` (already returned verbatim by the generic connection-read endpoints — no DTO changes needed). This keeps the mechanism entirely inside Erli's own plugin-local config shape; it does not touch the generic capability system. +**Second correction (found during #1401 review)**: the sentence above — "written/cleared by the backend in the same operation that writes/clears `allegroClientId`/`allegroClientSecret`" — does not hold in the shipped implementation. No single backend endpoint accepts both the Allegro credential pair and `config.allegroCategoryAccessEnabled` in one request; `PUT /connections/:id/credentials` and `PATCH /connections/:id` (config) remain independent, generic, single-purpose routes for every platform, with zero cross-write coupling. `ErliCredentialsPanel` instead sequences two **FE-orchestrated** mutations from one Save click — credentials first, then the config flag — ordered to fail safe (credentials-write failure skips the config patch entirely; a config-patch failure after a successful credentials write leaves the flag at its prior value and surfaces an inline retryable error). See the component's module header and the PR #1401 description's "Atomicity design decision" section for the full analysis. This ADR's Decision section is corrected accordingly. + ## Alternatives considered - **Require a real Allegro seller `Connection`, resolve its taxonomy from there**: rejected — forces Erli-only operators to create and maintain an unrelated Allegro seller account/connection just to browse a public catalog; also reintroduces the "which of N Allegro connections is the owner" ambiguity noted in `category-mapping.repository.ts`'s existing "Ambiguous borrowed-taxonomy category mapping" log. @@ -37,6 +39,8 @@ Erli owns its own small Allegro client-credentials HTTP client (`AllegroCategory - Small, self-contained duplication of OAuth `client_credentials` + category-fetch logic between the Allegro and Erli packages (~100-150 lines) rather than one shared implementation. - Per-connection Allegro app credentials mean each operator manages their own Allegro Developer Portal app registration (extra setup step, mitigated by the optional/recommended framing in the connection wizard). - Category/parameter data is genuinely seller-independent, so a future multi-tenant SaaS deployment may still want the shared-credential alternative — deferred, not precluded, by this design. +- **FE wizard step topology deviates from the approved mockup, deliberately** (flagged in the #1401 review, confirmed here as an accepted scope cut, not an oversight): the mockup keeps a constant 5-step shape, with the Category step itself rendering the plain-text fallback when Allegro access is off. The shipped `ErliCreateOfferWizard` instead collapses to 3 steps in that state, folding category entry into "Offer details." Functionally equivalent, fully tested, and simpler to reason about than a step that changes shape in place — but explicitly noted here so it isn't re-discovered as an unstated deviation. +- **No credential-verification status indicator on save** (also flagged in the #1401 review, confirmed as an accepted scope cut): the mockup shows a live "Not verified yet" / "Verification failed" badge and `aria-invalid` on the Allegro Client ID/Secret fields. This is deferred because there is no synchronous Allegro credential-verification call in the backend today — the `client_credentials` token exchange only happens lazily, on the first category-catalog fetch, so the FE has nothing to poll or await at save time. Tracked as a fast-follow candidate for once a synchronous verification endpoint exists (would need its own issue). **Migration path (if applicable):** - Purely additive: existing Erli connections without `allegroClientId`/`allegroClientSecret` keep today's plain-text category-ID behavior unchanged. diff --git a/docs/plugin-author-guide.md b/docs/plugin-author-guide.md index 4a80a2b02..6debf31d4 100644 --- a/docs/plugin-author-guide.md +++ b/docs/plugin-author-guide.md @@ -463,7 +463,7 @@ authoring your plugin). Its four fields: The `HostServices` bag passed into `register` and `createCapabilityAdapter` is defined at -[`libs/plugin-sdk/src/host-services.ts:54-163`](../libs/plugin-sdk/src/host-services.ts#L54-L163). +[`libs/plugin-sdk/src/host-services.ts:55-178`](../libs/plugin-sdk/src/host-services.ts#L55-L178). It splits into two blocks: - **Read inputs** (use): `logger`, `identifierMapping`, @@ -473,7 +473,8 @@ It splits into two blocks: `emailNormalizerRegistry`, `retryClassifierRegistry`, `schedulerTaskRegistry`, `webhookProvisioningRegistry`, `connectionConfigShapeValidatorRegistry`, - `connectionCredentialsShapeValidatorRegistry`. + `connectionCredentialsShapeValidatorRegistry`, + `connectionCredentialsRewriterRegistry`. **Plugin-specific cross-package deps** (your customer-projection repository, mapping-config service, etc.) are *not* in the @@ -1045,7 +1046,7 @@ If you're proposing a new "bulk" capability on a port, that's almost certainly a vertical-slice patterns, the PrestaShop opt-in helper. - [`libs/plugin-sdk/src/adapter-plugin.ts:42-110`](../libs/plugin-sdk/src/adapter-plugin.ts#L42-L110) — the `AdapterPlugin` contract spec, header-comment form. -- [`libs/plugin-sdk/src/host-services.ts:54-163`](../libs/plugin-sdk/src/host-services.ts#L54-L163) +- [`libs/plugin-sdk/src/host-services.ts:55-178`](../libs/plugin-sdk/src/host-services.ts#L55-L178) — the `HostServices` bag, fields split into read-inputs vs side registries. diff --git a/libs/core/src/integrations/domain/exceptions/connection-credentials-rewrite.exception.ts b/libs/core/src/integrations/domain/exceptions/connection-credentials-rewrite.exception.ts new file mode 100644 index 000000000..5e60112bc --- /dev/null +++ b/libs/core/src/integrations/domain/exceptions/connection-credentials-rewrite.exception.ts @@ -0,0 +1,30 @@ +/** + * ConnectionCredentialsRewriteException + * + * Thrown by `ConnectionCredentialsRewriterPort` implementations when the + * submitted credentials payload cannot be rewritten (e.g. a referenced + * sibling connection is the wrong kind, or a prerequisite field is missing). + * `ConnectionService` catches this at the API boundary and maps it to + * `BadRequestException`. + * + * Sibling of {@link InvalidCredentialsShapeException} — same rationale + * (plugins don't depend on NestJS exception types for failure paths). Kept + * as a distinct exception type rather than reusing + * `InvalidCredentialsShapeException` because a rewrite failure is a + * different concern from a shape failure: the payload's *shape* may be + * perfectly valid (e.g. `{ reuseAllegroConnectionId: "..." }`) while the + * *rewrite* still fails because the referenced id doesn't resolve to a + * usable source. + * + * @module libs/core/src/integrations/domain/exceptions + */ +export class ConnectionCredentialsRewriteException extends Error { + constructor( + public readonly pluginName: string, + detail: string + ) { + super(`Could not rewrite ${pluginName} credentials: ${detail}`); + this.name = 'ConnectionCredentialsRewriteException'; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/libs/core/src/integrations/domain/ports/connection-credentials-rewriter.port.ts b/libs/core/src/integrations/domain/ports/connection-credentials-rewriter.port.ts new file mode 100644 index 000000000..c3b7e3943 --- /dev/null +++ b/libs/core/src/integrations/domain/ports/connection-credentials-rewriter.port.ts @@ -0,0 +1,48 @@ +/** + * Connection Credentials Rewriter Port (#1387, ADR-031) + * + * Contract for per-plugin transformation of a raw `credentials` payload + * submitted on connection create / credential rotation, BEFORE it is merged + * onto the existing stored blob and shape-validated. Distinct from + * {@link ConnectionCredentialsShapeValidatorPort}: a validator only checks + * shape and never mutates the payload, while a rewriter is free to replace, + * add, or drop fields (e.g. resolving a caller-supplied reference into + * concrete secret values fetched server-side). + * + * Each plugin registers an adapter via + * `host.connectionCredentialsRewriterRegistry.register(adapterKey, …)` in + * `AdapterPlugin.register(host)` (or, when the rewriter needs a dependency + * outside the framework-neutral `HostServices` bag, from a companion NestJS + * module that injects `CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN` + * directly — mirroring the existing webhook-provisioner pattern). The + * registry is keyed by **adapterKey** (consistent with the other host + * registries). + * + * Implementations throw `ConnectionCredentialsRewriteException` (a core + * domain exception) on failure. `ConnectionService` catches and maps to + * `BadRequestException` at the API boundary. + * + * The port's contract is deliberately platform-neutral: it says nothing + * about *what* a plugin rewrites — only that it may transform the incoming + * payload before persistence. Any platform-specific field names or + * semantics (e.g. "resolve a sibling Allegro connection's app credentials") + * live entirely in the adapter implementation, never in this port. + * + * @module libs/core/src/integrations/domain/ports + * @see {@link ConnectionCredentialsRewriterRegistryService} for the registry + * @see {@link ConnectionCredentialsRewriteException} for the failure exception + */ +export interface ConnectionCredentialsRewriterPort { + /** + * Rewrite the raw credentials payload, returning the payload to actually + * merge/persist. Implementations that don't need to change anything for a + * given payload return it unchanged. + * + * @param credentials - The raw credential payload, exactly as the operator + * sent it. + * @returns The (possibly transformed) credentials payload. + * @throws ConnectionCredentialsRewriteException when the payload cannot be + * rewritten. + */ + rewrite(credentials: Record): Promise>; +} diff --git a/libs/core/src/integrations/index.ts b/libs/core/src/integrations/index.ts index 0b2c87b79..3cec9c9d5 100644 --- a/libs/core/src/integrations/index.ts +++ b/libs/core/src/integrations/index.ts @@ -19,6 +19,7 @@ export { InboundWebhookDecoderRegistryService } from './infrastructure/adapters/ export { EmailNormalizerRegistryService } from './infrastructure/adapters/email-normalizer-registry.service'; export { ConnectionConfigShapeValidatorRegistryService } from './infrastructure/adapters/connection-config-shape-validator-registry.service'; export { ConnectionCredentialsShapeValidatorRegistryService } from './infrastructure/adapters/connection-credentials-shape-validator-registry.service'; +export { ConnectionCredentialsRewriterRegistryService } from './infrastructure/adapters/connection-credentials-rewriter-registry.service'; export { OAuthCompletionRegistryService } from './infrastructure/adapters/oauth-completion-registry.service'; // Ports @@ -32,6 +33,7 @@ export { InboundWebhookDecoderPort } from './domain/ports/inbound-webhook-decode export { EmailNormalizerPort } from './domain/ports/email-normalizer.port'; export { ConnectionConfigShapeValidatorPort } from './domain/ports/connection-config-shape-validator.port'; export { ConnectionCredentialsShapeValidatorPort } from './domain/ports/connection-credentials-shape-validator.port'; +export { ConnectionCredentialsRewriterPort } from './domain/ports/connection-credentials-rewriter.port'; export { OAuthCompletionPort } from './domain/ports/oauth-completion.port'; export { WebhookSecretProviderPort, @@ -83,6 +85,7 @@ export { DuplicateAdapterKeyException } from './domain/exceptions/duplicate-adap export { DuplicatePlatformDefaultException } from './domain/exceptions/duplicate-platform-default.exception'; export { InvalidConnectionConfigException } from './domain/exceptions/invalid-connection-config.exception'; export { InvalidCredentialsShapeException } from './domain/exceptions/invalid-credentials-shape.exception'; +export { ConnectionCredentialsRewriteException } from './domain/exceptions/connection-credentials-rewrite.exception'; export { OAuthCodeExchangeException } from './domain/exceptions/oauth-code-exchange.exception'; export { flattenValidationErrors, diff --git a/libs/core/src/integrations/infrastructure/adapters/connection-credentials-rewriter-registry.service.ts b/libs/core/src/integrations/infrastructure/adapters/connection-credentials-rewriter-registry.service.ts new file mode 100644 index 000000000..9cc1bd090 --- /dev/null +++ b/libs/core/src/integrations/infrastructure/adapters/connection-credentials-rewriter-registry.service.ts @@ -0,0 +1,33 @@ +/** + * Connection Credentials Rewriter Registry Service (#1387, ADR-031) + * + * Holds `ConnectionCredentialsRewriterPort` implementations keyed by + * `adapterKey`. Plugins self-register via + * `host.connectionCredentialsRewriterRegistry.register(...)` at boot (or, for + * rewriters that need a dependency outside `HostServices`, from a companion + * NestJS module injecting this service's token directly). Consumed by + * `ConnectionService.updateCredentials` before merge + shape validation. + * Sibling of {@link ConnectionCredentialsShapeValidatorRegistryService}. + * + * @module libs/core/src/integrations/infrastructure/adapters + * @see {@link ConnectionCredentialsRewriterPort} for the port interface + */ +import { Injectable } from '@nestjs/common'; +import type { ConnectionCredentialsRewriterPort } from '../../domain/ports/connection-credentials-rewriter.port'; + +@Injectable() +export class ConnectionCredentialsRewriterRegistryService { + private readonly rewriters: Map = new Map(); + + register(adapterKey: string, rewriter: ConnectionCredentialsRewriterPort): void { + this.rewriters.set(adapterKey, rewriter); + } + + get(adapterKey: string): ConnectionCredentialsRewriterPort | undefined { + return this.rewriters.get(adapterKey); + } + + has(adapterKey: string): boolean { + return this.rewriters.has(adapterKey); + } +} diff --git a/libs/core/src/integrations/integrations.module.ts b/libs/core/src/integrations/integrations.module.ts index 5800f51ec..4ce7080ef 100644 --- a/libs/core/src/integrations/integrations.module.ts +++ b/libs/core/src/integrations/integrations.module.ts @@ -23,6 +23,7 @@ import { InboundWebhookDecoderRegistryService } from './infrastructure/adapters/ import { EmailNormalizerRegistryService } from './infrastructure/adapters/email-normalizer-registry.service'; import { ConnectionConfigShapeValidatorRegistryService } from './infrastructure/adapters/connection-config-shape-validator-registry.service'; import { ConnectionCredentialsShapeValidatorRegistryService } from './infrastructure/adapters/connection-credentials-shape-validator-registry.service'; +import { ConnectionCredentialsRewriterRegistryService } from './infrastructure/adapters/connection-credentials-rewriter-registry.service'; import { OAuthCompletionRegistryService } from './infrastructure/adapters/oauth-completion-registry.service'; import { CredentialsWebhookSecretAdapter } from './infrastructure/adapters/credentials-webhook-secret.adapter'; import { WebhookSecretService } from './application/services/webhook-secret.service'; @@ -45,6 +46,7 @@ import { EMAIL_NORMALIZER_REGISTRY_TOKEN, CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, } from './integrations.tokens'; @@ -65,6 +67,7 @@ export { EMAIL_NORMALIZER_REGISTRY_TOKEN, CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, } from './integrations.tokens'; @@ -86,6 +89,7 @@ export { EmailNormalizerRegistryService, ConnectionConfigShapeValidatorRegistryService, ConnectionCredentialsShapeValidatorRegistryService, + ConnectionCredentialsRewriterRegistryService, OAuthCompletionRegistryService, CredentialsWebhookSecretAdapter, WebhookSecretService, @@ -136,6 +140,10 @@ export { provide: CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, useExisting: ConnectionCredentialsShapeValidatorRegistryService, }, + { + provide: CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + useExisting: ConnectionCredentialsRewriterRegistryService, + }, { provide: INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, useExisting: OAuthCompletionRegistryService, @@ -169,6 +177,7 @@ export { EMAIL_NORMALIZER_REGISTRY_TOKEN, CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, WEBHOOK_SECRET_PROVIDER_TOKEN, WEBHOOK_SECRET_SERVICE_TOKEN, @@ -184,6 +193,7 @@ export { EmailNormalizerRegistryService, ConnectionConfigShapeValidatorRegistryService, ConnectionCredentialsShapeValidatorRegistryService, + ConnectionCredentialsRewriterRegistryService, OAuthCompletionRegistryService, WebhookSecretService, ], diff --git a/libs/core/src/integrations/integrations.tokens.ts b/libs/core/src/integrations/integrations.tokens.ts index 7a6a98c45..ad3e38d2d 100644 --- a/libs/core/src/integrations/integrations.tokens.ts +++ b/libs/core/src/integrations/integrations.tokens.ts @@ -36,4 +36,7 @@ export const CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN = Symbol( export const INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN = Symbol( 'OAuthCompletionRegistryService', ); +export const CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN = Symbol( + 'ConnectionCredentialsRewriterRegistryService', +); diff --git a/libs/integrations/allegro/src/allegro-integration.module.ts b/libs/integrations/allegro/src/allegro-integration.module.ts index c1682cce0..be1d35e7c 100644 --- a/libs/integrations/allegro/src/allegro-integration.module.ts +++ b/libs/integrations/allegro/src/allegro-integration.module.ts @@ -39,6 +39,8 @@ import { ConnectionConfigShapeValidatorRegistryService, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, ConnectionCredentialsShapeValidatorRegistryService, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + ConnectionCredentialsRewriterRegistryService, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, OAuthCompletionRegistryService, CREDENTIALS_SERVICE_TOKEN, @@ -129,6 +131,8 @@ export class AllegroIntegrationModule implements OnModuleInit { private readonly connectionConfigShapeValidatorRegistry: ConnectionConfigShapeValidatorRegistryService, @Inject(CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN) private readonly connectionCredentialsShapeValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService, + @Inject(CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN) + private readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService, @Inject(INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN) private readonly oauthCompletionRegistry: OAuthCompletionRegistryService, @Inject(RETRY_CLASSIFIER_REGISTRY_TOKEN) @@ -192,6 +196,7 @@ export class AllegroIntegrationModule implements OnModuleInit { inboundWebhookDecoderRegistry: this.inboundWebhookDecoderRegistry, connectionConfigShapeValidatorRegistry: this.connectionConfigShapeValidatorRegistry, connectionCredentialsShapeValidatorRegistry: this.connectionCredentialsShapeValidatorRegistry, + connectionCredentialsRewriterRegistry: this.connectionCredentialsRewriterRegistry, oauthCompletionRegistry: this.oauthCompletionRegistry, }; 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/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 186b790d9..aaffb81dd 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-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 + * 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-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-031). + */ +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,22 @@ 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-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/erli-credentials-rewriter.module.ts b/libs/integrations/erli/src/erli-credentials-rewriter.module.ts new file mode 100644 index 000000000..55417cffb --- /dev/null +++ b/libs/integrations/erli/src/erli-credentials-rewriter.module.ts @@ -0,0 +1,50 @@ +/** + * Erli Credentials Rewriter Module (#1387, ADR-031) + * + * Companion NestJS module composed into `ErliIntegrationModule`. It exists + * because `ErliAllegroCredentialsRewriterAdapter` needs a NestJS-injected + * `ConnectionPort` (to resolve the referenced sibling Allegro connection) — + * deliberately NOT part of the framework-neutral `HostServices` bag. Rather + * than restructure the whole Erli plugin, this small module injects exactly + * `ConnectionPort` + `CredentialsResolverPort` + the rewriter registry, and + * registers the adapter in `onModuleInit`. Mirrors + * `ErliWebhookProvisioningModule`'s shape. + * + * @module libs/integrations/erli/src + */ +import { Inject, Module, type OnModuleInit } from '@nestjs/common'; +import { + CredentialsResolverPort, + CREDENTIALS_RESOLVER_TOKEN, + IntegrationsModule, + ConnectionCredentialsRewriterRegistryService, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, +} from '@openlinker/core/integrations'; +import { + ConnectionPort, + CONNECTION_PORT_TOKEN, + IdentifierMappingModule, +} from '@openlinker/core/identifier-mapping'; +import { ERLI_ADAPTER_KEY } from './erli.constants'; +import { ErliAllegroCredentialsRewriterAdapter } from './infrastructure/adapters/erli-allegro-credentials-rewriter.adapter'; + +@Module({ + imports: [IntegrationsModule, IdentifierMappingModule], +}) +export class ErliCredentialsRewriterModule implements OnModuleInit { + constructor( + @Inject(CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN) + private readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService, + @Inject(CONNECTION_PORT_TOKEN) + private readonly connectionPort: ConnectionPort, + @Inject(CREDENTIALS_RESOLVER_TOKEN) + private readonly credentialsResolver: CredentialsResolverPort + ) {} + + onModuleInit(): void { + this.connectionCredentialsRewriterRegistry.register( + ERLI_ADAPTER_KEY, + new ErliAllegroCredentialsRewriterAdapter(this.connectionPort, this.credentialsResolver) + ); + } +} diff --git a/libs/integrations/erli/src/erli-integration.module.ts b/libs/integrations/erli/src/erli-integration.module.ts index 722f35c6b..c240380e9 100644 --- a/libs/integrations/erli/src/erli-integration.module.ts +++ b/libs/integrations/erli/src/erli-integration.module.ts @@ -34,6 +34,8 @@ import { ConnectionConfigShapeValidatorRegistryService, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, ConnectionCredentialsShapeValidatorRegistryService, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + ConnectionCredentialsRewriterRegistryService, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, OAuthCompletionRegistryService, CREDENTIALS_RESOLVER_TOKEN, @@ -64,6 +66,7 @@ import { CACHE_PORT_TOKEN, type CachePort } from '@openlinker/shared'; import type { HostServices } from '@openlinker/plugin-sdk'; import { createErliPlugin } from './erli-plugin'; import { ErliWebhookProvisioningModule } from './erli-webhook-provisioning.module'; +import { ErliCredentialsRewriterModule } from './erli-credentials-rewriter.module'; @Module({ imports: [ @@ -75,6 +78,11 @@ import { ErliWebhookProvisioningModule } from './erli-webhook-provisioning.modul // + IWebhookSecretService (not in HostServices), so it self-registers from // this companion module rather than from plugin.register(host). ErliWebhookProvisioningModule, + // #1387: the Allegro-credentials-reuse rewriter needs NestJS-injected + // ConnectionPort (not in HostServices), so it self-registers from this + // companion module rather than from plugin.register(host) — same shape + // as ErliWebhookProvisioningModule above. + ErliCredentialsRewriterModule, ], }) export class ErliIntegrationModule implements OnModuleInit { @@ -99,6 +107,8 @@ export class ErliIntegrationModule implements OnModuleInit { private readonly connectionConfigShapeValidatorRegistry: ConnectionConfigShapeValidatorRegistryService, @Inject(CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN) private readonly connectionCredentialsShapeValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService, + @Inject(CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN) + private readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService, @Inject(INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN) private readonly oauthCompletionRegistry: OAuthCompletionRegistryService, @Inject(RETRY_CLASSIFIER_REGISTRY_TOKEN) @@ -140,6 +150,7 @@ export class ErliIntegrationModule implements OnModuleInit { inboundWebhookDecoderRegistry: this.inboundWebhookDecoderRegistry, connectionConfigShapeValidatorRegistry: this.connectionConfigShapeValidatorRegistry, connectionCredentialsShapeValidatorRegistry: this.connectionCredentialsShapeValidatorRegistry, + connectionCredentialsRewriterRegistry: this.connectionCredentialsRewriterRegistry, oauthCompletionRegistry: this.oauthCompletionRegistry, }; diff --git a/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-allegro-credentials-rewriter.adapter.spec.ts b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-allegro-credentials-rewriter.adapter.spec.ts new file mode 100644 index 000000000..1e23fe93f --- /dev/null +++ b/libs/integrations/erli/src/infrastructure/adapters/__tests__/erli-allegro-credentials-rewriter.adapter.spec.ts @@ -0,0 +1,113 @@ +/** + * Erli Allegro-Credentials-Reuse Rewriter Tests (#1387, ADR-031) + * + * Asserts the `reuseAllegroConnectionId` shape resolves server-side into a + * concrete `allegroClientId`/`allegroClientSecret` pair, so the raw Allegro + * `clientSecret` is never serialized into an HTTP response body; and that a + * payload without `reuseAllegroConnectionId` passes through unchanged. + * + * @module libs/integrations/erli/src/infrastructure/adapters/__tests__ + */ +import type { Connection, ConnectionPort } from '@openlinker/core/identifier-mapping'; +import { ConnectionNotFoundException } from '@openlinker/core/identifier-mapping'; +import { ConnectionCredentialsRewriteException } from '@openlinker/core/integrations'; +import type { CredentialsResolverPort } from '@openlinker/core/integrations'; +import { ErliAllegroCredentialsRewriterAdapter } from '../erli-allegro-credentials-rewriter.adapter'; + +function buildAllegroConnection(overrides: Partial = {}): Connection { + return { + id: 'allegro-conn-1', + platformType: 'allegro', + name: 'Main Allegro Store', + status: 'active', + config: {}, + credentialsRef: 'db:allegro-cred-ref-1', + ...overrides, + } as Connection; +} + +describe('ErliAllegroCredentialsRewriterAdapter', () => { + let connectionPort: jest.Mocked>; + let credentialsResolver: jest.Mocked; + let adapter: ErliAllegroCredentialsRewriterAdapter; + + beforeEach(() => { + connectionPort = { get: jest.fn() }; + credentialsResolver = { get: jest.fn() }; + adapter = new ErliAllegroCredentialsRewriterAdapter( + connectionPort as unknown as ConnectionPort, + credentialsResolver + ); + }); + + it('should return the credentials unchanged when reuseAllegroConnectionId is absent', async () => { + const result = await adapter.rewrite({ apiKey: 'unchanged' }); + + expect(result).toEqual({ apiKey: 'unchanged' }); + expect(connectionPort.get).not.toHaveBeenCalled(); + }); + + it('should copy clientId/clientSecret from the source Allegro connection, dropping reuseAllegroConnectionId', async () => { + connectionPort.get.mockResolvedValue(buildAllegroConnection()); + credentialsResolver.get.mockResolvedValue({ + clientId: 'reused-client-id', + clientSecret: 'reused-client-secret', + }); + + const result = await adapter.rewrite({ + apiKey: 'keep-me', + reuseAllegroConnectionId: 'allegro-conn-1', + }); + + expect(connectionPort.get).toHaveBeenCalledWith('allegro-conn-1'); + expect(credentialsResolver.get).toHaveBeenCalledWith('db:allegro-cred-ref-1'); + expect(result).toEqual({ + apiKey: 'keep-me', + allegroClientId: 'reused-client-id', + allegroClientSecret: 'reused-client-secret', + }); + }); + + it('should reject a blank reuseAllegroConnectionId', async () => { + await expect(adapter.rewrite({ reuseAllegroConnectionId: ' ' })).rejects.toThrow( + ConnectionCredentialsRewriteException + ); + expect(connectionPort.get).not.toHaveBeenCalled(); + }); + + it('should reject when the source connection id does not resolve to an existing connection', async () => { + connectionPort.get.mockRejectedValue(new ConnectionNotFoundException('does-not-exist')); + + await expect( + adapter.rewrite({ reuseAllegroConnectionId: 'does-not-exist' }) + ).rejects.toThrow(/does not exist/); + }); + + it('should reject when the source connection is not an Allegro connection', async () => { + connectionPort.get.mockResolvedValue( + buildAllegroConnection({ id: 'other-conn-1', platformType: 'prestashop' }) + ); + + await expect( + adapter.rewrite({ reuseAllegroConnectionId: 'other-conn-1' }) + ).rejects.toThrow(/is not an Allegro connection/); + }); + + it('should reject when the source Allegro connection has no client credentials configured', async () => { + connectionPort.get.mockResolvedValue(buildAllegroConnection()); + credentialsResolver.get.mockResolvedValue({}); + + await expect( + adapter.rewrite({ reuseAllegroConnectionId: 'allegro-conn-1' }) + ).rejects.toThrow(/does not have app client credentials/); + }); + + it('should propagate unexpected errors from ConnectionPort.get unchanged', async () => { + const unexpected = new Error('db unavailable'); + connectionPort.get.mockRejectedValue(unexpected); + + await expect(adapter.rewrite({ reuseAllegroConnectionId: 'allegro-conn-1' })).rejects.toThrow( + unexpected + ); + }); +}); 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-allegro-credentials-rewriter.adapter.ts b/libs/integrations/erli/src/infrastructure/adapters/erli-allegro-credentials-rewriter.adapter.ts new file mode 100644 index 000000000..1fb5b45a7 --- /dev/null +++ b/libs/integrations/erli/src/infrastructure/adapters/erli-allegro-credentials-rewriter.adapter.ts @@ -0,0 +1,106 @@ +/** + * Erli Allegro-Credentials-Reuse Rewriter (#1387, ADR-031) + * + * Implements `ConnectionCredentialsRewriterPort` for the Erli adapter key. + * Resolves the `reuseAllegroConnectionId` shape into a concrete + * `allegroClientId`/`allegroClientSecret` pair, server-side, so the raw + * Allegro `clientSecret` is never serialized into an HTTP response body. + * Registered against `ConnectionCredentialsRewriterRegistryService` at + * `erli.shopapi.v1` from `ErliCredentialsRewriterModule` (not from + * `erli-plugin.ts#register(host)`) because it needs `ConnectionPort` — a + * NestJS-injected dependency deliberately kept out of the framework-neutral + * `HostServices` bag — mirroring `ErliWebhookProvisioningAdapter`'s wiring. + * + * "Belongs to the operator's own tenant" (per the issue's acceptance + * criteria) has no dedicated model in this codebase today — there is no + * `tenantId`/`organizationId` anywhere on `Connection` or `AuthenticatedUser`; + * `@Roles('admin')` on the credentials-rotation endpoint is the only access + * boundary that exists, and it already applies uniformly to every connection + * in the single OpenLinker deployment. The closest enforceable "ownership" + * check available is that the source id resolves to a real, existing + * connection of `platformType: 'allegro'` in this same instance — anything + * else (a missing id, or an id for a non-Allegro connection) is rejected + * below. + * + * @module libs/integrations/erli/src/infrastructure/adapters + * @see {@link ConnectionCredentialsRewriterPort} + */ +import { + type ConnectionCredentialsRewriterPort, + ConnectionCredentialsRewriteException, + type CredentialsResolverPort, +} from '@openlinker/core/integrations'; +import { + type Connection, + type ConnectionPort, + ConnectionNotFoundException, +} from '@openlinker/core/identifier-mapping'; +import { Logger } from '@openlinker/shared/logging'; + +interface AllegroAppCredentials { + clientId?: string; + clientSecret?: string; +} + +export class ErliAllegroCredentialsRewriterAdapter implements ConnectionCredentialsRewriterPort { + private readonly logger = new Logger(ErliAllegroCredentialsRewriterAdapter.name); + + constructor( + private readonly connectionPort: ConnectionPort, + private readonly credentialsResolver: CredentialsResolverPort, + private readonly pluginName: string = 'Erli' + ) {} + + async rewrite(credentials: Record): Promise> { + const { reuseAllegroConnectionId, ...rest } = credentials; + if (reuseAllegroConnectionId === undefined) { + return credentials; + } + if (typeof reuseAllegroConnectionId !== 'string' || reuseAllegroConnectionId.trim() === '') { + throw new ConnectionCredentialsRewriteException( + this.pluginName, + '`reuseAllegroConnectionId` must be a non-empty string' + ); + } + + let sourceConnection: Connection; + try { + sourceConnection = await this.connectionPort.get(reuseAllegroConnectionId); + } catch (error) { + if (error instanceof ConnectionNotFoundException) { + throw new ConnectionCredentialsRewriteException( + this.pluginName, + `connection ${reuseAllegroConnectionId} does not exist` + ); + } + throw error; + } + if (sourceConnection.platformType !== 'allegro') { + throw new ConnectionCredentialsRewriteException( + this.pluginName, + `connection ${reuseAllegroConnectionId} is not an Allegro connection ` + + `(platformType: ${sourceConnection.platformType}); cannot reuse its credentials` + ); + } + + const sourceCredentials = await this.credentialsResolver.get( + sourceConnection.credentialsRef + ); + if (!sourceCredentials.clientId || !sourceCredentials.clientSecret) { + throw new ConnectionCredentialsRewriteException( + this.pluginName, + `Allegro connection ${reuseAllegroConnectionId} does not have app client credentials ` + + '(clientId/clientSecret) configured to reuse' + ); + } + + this.logger.log( + `Reusing Allegro app credentials from connection ${reuseAllegroConnectionId} for credential update` + ); + return { + ...rest, + allegroClientId: sourceCredentials.clientId, + allegroClientSecret: sourceCredentials.clientSecret, + }; + } +} 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 new file mode 100644 index 000000000..2aa811720 --- /dev/null +++ b/libs/integrations/erli/src/infrastructure/http/__tests__/allegro-category-catalog-client.spec.ts @@ -0,0 +1,532 @@ +/** + * 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('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 + .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..49b58b42e --- /dev/null +++ b/libs/integrations/erli/src/infrastructure/http/allegro-category-catalog-client.ts @@ -0,0 +1,318 @@ +/** + * 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-031). + * + * 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-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 { + CategoryParameter, + 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'; +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, + 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. + */ + 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`. + * + * 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.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(() => { + this.inFlightToken = undefined; + }); + } + 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'); + + 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-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 + * 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; +} diff --git a/libs/integrations/prestashop/src/prestashop-integration.module.ts b/libs/integrations/prestashop/src/prestashop-integration.module.ts index 978f1a5c0..e196f3732 100644 --- a/libs/integrations/prestashop/src/prestashop-integration.module.ts +++ b/libs/integrations/prestashop/src/prestashop-integration.module.ts @@ -43,6 +43,8 @@ import { ConnectionConfigShapeValidatorRegistryService, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, ConnectionCredentialsShapeValidatorRegistryService, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + ConnectionCredentialsRewriterRegistryService, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, OAuthCompletionRegistryService, WEBHOOK_SECRET_PROVIDER_TOKEN, @@ -117,6 +119,8 @@ export class PrestashopIntegrationModule implements OnModuleInit { private readonly connectionConfigShapeValidatorRegistry: ConnectionConfigShapeValidatorRegistryService, @Inject(CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN) private readonly connectionCredentialsShapeValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService, + @Inject(CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN) + private readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService, @Inject(INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN) private readonly oauthCompletionRegistry: OAuthCompletionRegistryService, @Inject(RETRY_CLASSIFIER_REGISTRY_TOKEN) @@ -175,6 +179,7 @@ export class PrestashopIntegrationModule implements OnModuleInit { inboundWebhookDecoderRegistry: this.inboundWebhookDecoderRegistry, connectionConfigShapeValidatorRegistry: this.connectionConfigShapeValidatorRegistry, connectionCredentialsShapeValidatorRegistry: this.connectionCredentialsShapeValidatorRegistry, + connectionCredentialsRewriterRegistry: this.connectionCredentialsRewriterRegistry, oauthCompletionRegistry: this.oauthCompletionRegistry, }; diff --git a/libs/plugin-sdk/src/create-nest-adapter-module.spec.ts b/libs/plugin-sdk/src/create-nest-adapter-module.spec.ts index ec2a36784..7bcbbbe28 100644 --- a/libs/plugin-sdk/src/create-nest-adapter-module.spec.ts +++ b/libs/plugin-sdk/src/create-nest-adapter-module.spec.ts @@ -26,6 +26,7 @@ import { INBOUND_WEBHOOK_DECODER_REGISTRY_TOKEN, CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, CREDENTIALS_RESOLVER_TOKEN, } from '@openlinker/core/integrations'; @@ -63,6 +64,7 @@ describe('createNestAdapterModule', () => { inboundWebhookDecoderRegistry: object; connectionConfigShapeValidatorRegistry: object; connectionCredentialsShapeValidatorRegistry: object; + connectionCredentialsRewriterRegistry: object; oauthCompletionRegistry: object; retryClassifierRegistry: object; authFailureClassifierRegistry: object; @@ -80,6 +82,7 @@ describe('createNestAdapterModule', () => { inboundWebhookDecoderRegistry: {}, connectionConfigShapeValidatorRegistry: {}, connectionCredentialsShapeValidatorRegistry: {}, + connectionCredentialsRewriterRegistry: {}, oauthCompletionRegistry: {}, retryClassifierRegistry: {}, authFailureClassifierRegistry: {}, @@ -122,6 +125,10 @@ describe('createNestAdapterModule', () => { provide: CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, useValue: registries.connectionCredentialsShapeValidatorRegistry, }, + { + provide: CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + useValue: registries.connectionCredentialsRewriterRegistry, + }, { provide: INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, useValue: registries.oauthCompletionRegistry, diff --git a/libs/plugin-sdk/src/create-nest-adapter-module.ts b/libs/plugin-sdk/src/create-nest-adapter-module.ts index 7de6217c5..02ca00686 100644 --- a/libs/plugin-sdk/src/create-nest-adapter-module.ts +++ b/libs/plugin-sdk/src/create-nest-adapter-module.ts @@ -50,6 +50,8 @@ import { ConnectionConfigShapeValidatorRegistryService, CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN, ConnectionCredentialsShapeValidatorRegistryService, + CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN, + ConnectionCredentialsRewriterRegistryService, INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN, OAuthCompletionRegistryService, CREDENTIALS_RESOLVER_TOKEN, @@ -118,6 +120,8 @@ export function createNestAdapterModule(options: CreateNestAdapterModuleOptions) private readonly connectionConfigShapeValidatorRegistry: ConnectionConfigShapeValidatorRegistryService, @Inject(CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN) private readonly connectionCredentialsShapeValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService, + @Inject(CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN) + private readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService, @Inject(INTEGRATIONS_OAUTH_COMPLETION_REGISTRY_TOKEN) private readonly oauthCompletionRegistry: OAuthCompletionRegistryService, @Inject(RETRY_CLASSIFIER_REGISTRY_TOKEN) @@ -158,6 +162,7 @@ export function createNestAdapterModule(options: CreateNestAdapterModuleOptions) connectionConfigShapeValidatorRegistry: this.connectionConfigShapeValidatorRegistry, connectionCredentialsShapeValidatorRegistry: this.connectionCredentialsShapeValidatorRegistry, + connectionCredentialsRewriterRegistry: this.connectionCredentialsRewriterRegistry, oauthCompletionRegistry: this.oauthCompletionRegistry, }; diff --git a/libs/plugin-sdk/src/host-services.ts b/libs/plugin-sdk/src/host-services.ts index 5a624b6c0..f216e48d9 100644 --- a/libs/plugin-sdk/src/host-services.ts +++ b/libs/plugin-sdk/src/host-services.ts @@ -42,6 +42,7 @@ import type { InboundWebhookDecoderRegistryService, ConnectionConfigShapeValidatorRegistryService, ConnectionCredentialsShapeValidatorRegistryService, + ConnectionCredentialsRewriterRegistryService, OAuthCompletionRegistryService, CredentialsResolverPort, } from '@openlinker/core/integrations'; @@ -151,6 +152,20 @@ export interface HostServices { */ readonly connectionCredentialsShapeValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService; + /** + * Per-plugin credentials-rewriter registry (#1387, ADR-031). A plugin + * registers a `ConnectionCredentialsRewriterPort` here to transform the raw + * credentials payload BEFORE it is merged onto the existing stored blob + * and shape-validated — e.g. resolving a caller-supplied reference into + * concrete secret values fetched server-side. Absence is a deliberate + * skip — most plugins persist the submitted payload verbatim. Rewriters + * that need a dependency outside this bag (e.g. `ConnectionPort` to + * resolve a sibling connection) register from a companion NestJS module + * that injects this registry's token directly instead, mirroring the + * existing webhook-provisioner pattern. + */ + readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService; + /** * Per-plugin OAuth-completion registry (#859). A plugin whose platform uses * an OAuth2 authorization-code flow registers an `OAuthCompletionPort` here diff --git a/scripts/check-plugin-guide-quotes.mjs b/scripts/check-plugin-guide-quotes.mjs index ea5ba8b9c..58b4d3275 100644 --- a/scripts/check-plugin-guide-quotes.mjs +++ b/scripts/check-plugin-guide-quotes.mjs @@ -18,7 +18,7 @@ * 110; the link still resolves but lands on the wrong content. * * 3. **Boundary check** for the `HostServices` interface at - * `libs/plugin-sdk/src/host-services.ts:54-163`. Same shape. + * `libs/plugin-sdk/src/host-services.ts:55-178`. Same shape. * * Both checks are deliberately strict: when they fire on a benign * refactor (e.g., new import above the interface), the human updates @@ -68,9 +68,9 @@ const BOUNDARY_REFS = [ { label: 'HostServices', sourceFile: 'libs/plugin-sdk/src/host-services.ts', - sourceStart: 54, - sourceEnd: 163, - guideLinkSubstring: 'host-services.ts:54-163', + sourceStart: 55, + sourceEnd: 178, + guideLinkSubstring: 'host-services.ts:55-178', expectedStart: /^export interface HostServices \{$/, expectedEnd: /^\}\s*$/, },