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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,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<ICredentialsService>;

Expand Down Expand Up @@ -815,6 +823,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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,16 @@ export class ConnectionService implements IConnectionService {
platformType: connection.platformType,
adapterKey: connection.adapterKey,
});
await this.validateCredentialsShape(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, ...credentials };
await this.validateCredentialsShape(metadata.adapterKey, mergedCredentials);
await this.credentials.update(ref, { credentialsJson: mergedCredentials });
this.logger.log(`Rotated credentials for connection ${connectionId}`);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ function readString(params: Record<string, unknown> | 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
<ErliCreateOfferWizard connection={erliConnection} onCancel={vi.fn()} onSubmitted={vi.fn()} />,
{ 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(
<ErliCreateOfferWizard
connection={erliConnectionWithCategoryAccess}
onCancel={vi.fn()}
onSubmitted={vi.fn()}
/>,
{ 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(
<ErliCreateOfferWizard
connection={erliConnectionWithCategoryAccess}
onCancel={vi.fn()}
onSubmitted={vi.fn()}
/>,
{ 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();
});
});
});
Loading