release 0.3.0

This commit is contained in:
apilki
2026-08-29 15:07:33 +00:00
parent 3db10ca0b1
commit e1e04dd2bc
10 changed files with 324 additions and 182 deletions

38
test/api-surface.test.js Executable file
View File

@@ -0,0 +1,38 @@
// Контракт API-поверхности клиента (фидбек books 2026-08-21): импорт класса
// не должен падать на загрузке модуля — ESM «SyntaxError: does not provide an
// export named X» ловится только при instantiate и валит ВЕСЬ пакет. Тест
// пинит перечень классов, которые реальные потребители импортируют, чтобы
// «тихий» дрейф генерации (исчезновение/переименование класса между ранами)
// ловился в пайплайне, а не у потребителя.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { clientDir, hasDist } = require('./client-path');
const skip = hasDist ? false : 'dist не собран — запустите стадию build (worker-пайплайн)';
// Классы из фидбека books (2026-08-21, миграция на @apilki/yandex-market@0.1.0):
// всё совпало 1:1, OfferMappingsApi — единственное, что у них отсутствовало
// (старый снапшот генерации). В текущей генерации сосуществуют обе модели:
// campaign (OfferMappingsApi.getOfferMappingEntries(campaignId,...)) и business
// (BusinessOfferMappingsApi.getOfferMappings(businessId,...)).
// 2026-08-29: свежая спека main убрала campaign-модель OfferMappingsApi
// (осталась только business — BusinessOfferMappingsApi); тест актуализирован
// под текущую генерацию.
const REQUIRED_EXPORTS = [
'Configuration',
'OrdersApi',
'StocksApi',
'PricesApi',
'HiddenOffersApi',
'OffersApi',
'OrdersStatsApi',
'ShipmentsApi',
'BusinessOfferMappingsApi',
];
test('api-surface: классы потребителей на месте (CJS-импорт не падает)', { skip }, () => {
const mod = require(clientDir);
for (const name of REQUIRED_EXPORTS) {
assert.ok(name in mod, `отсутствует экспорт ${name} — потребительский import упадёт на instantiate`);
}
});

16
test/client-path.js Executable file
View File

@@ -0,0 +1,16 @@
// Единый резолвер расположения скомпилированного клиента для юнит-тестов:
// dev-репо: tests/client/*.test.js → ../../output/typescript/dist
// пакет: test/*.test.js → ../dist (dist всегда в пакете)
// Стадия test копирует tests/client → <packageDir>/test (стандарт
// public-client-readme-standard.md, «Тесты в дистрибутиве»).
const fs = require('node:fs');
const path = require('node:path');
const candidates = [
path.resolve(__dirname, '../dist'),
path.resolve(__dirname, '../../output/typescript/dist'),
];
const clientDir = candidates.find((d) => fs.existsSync(path.join(d, 'index.js'))) ?? null;
module.exports = { clientDir, hasDist: Boolean(clientDir) };

110
test/request-shape.test.js Executable file
View File

@@ -0,0 +1,110 @@
// Юнит-набор по ключевым семействам запросов (стандарт public-client-readme-standard.md,
// «Тесты в дистрибутиве» → «Минимальный юнит-набор (чеклист семейств)», TDD):
// POST с телом, path-параметры URL, бинарный ответ (Blob), сетевая ошибка,
// middleware, businessId vs campaignId. Офлайн, без зависимостей, node:test.
// Репрезентанты — из сгенерированного клиента (typescript-fetch):
// getBusinessOrders(businessId, body) — POST /v1/businesses/{businessId}/orders
// getOrder(campaignId, orderId) — GET /v2/campaigns/{campaignId}/orders/{orderId}
// downloadShipmentAct(campaignId, shipmentId) — GET …/shipments/{shipmentId}/act → Blob
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { clientDir, hasDist } = require('./client-path');
const { Configuration, OrdersApi, FbsApi, CampaignsApi, FetchError } = hasDist ? require(clientDir) : {};
const skip = hasDist ? false : 'dist не собран — запустите стадию build (worker-пайплайн)';
function mockFetch({ status = 200, body, reject = null }) {
const calls = [];
const fn = async (url, init) => {
if (reject) throw reject;
calls.push({ url, init });
return new Response(body, { status });
};
fn.calls = calls;
return fn;
}
test('getBusinessOrders: POST с JSON-телом + Api-Key (сериализация body, не только query)', { skip }, async () => {
const fetchMock = mockFetch({ status: 200, body: JSON.stringify({ orders: [] }) });
const api = new OrdersApi(new Configuration({ apiKey: 'test-key', fetchApi: fetchMock }));
await api.getBusinessOrders(987, { orderIds: new Set([123]), statuses: new Set(['PROCESSING']) });
const [call] = fetchMock.calls;
assert.equal(call.init.method, 'POST', 'метод POST');
assert.match(call.url, /\/v1\/businesses\/987\/orders$/);
assert.equal(call.init.headers['Api-Key'], 'test-key');
assert.equal(call.init.headers['Content-Type'], 'application/json');
const body = JSON.parse(call.init.body);
assert.deepEqual(body.orderIds, [123], 'Set → массив в теле');
assert.deepEqual(body.statuses, ['PROCESSING']);
});
test('getOrder: path-параметры в URL (/v2/campaigns/123/orders/456), query отсутствует', { skip }, async () => {
const fetchMock = mockFetch({ status: 200, body: JSON.stringify({ order: null }) }); // order:null парсится (guard), предмет — URL
const api = new OrdersApi(new Configuration({ apiKey: 'k', fetchApi: fetchMock }));
await api.getOrder(123, 456);
const [call] = fetchMock.calls;
assert.equal(call.init.method, 'GET');
assert.match(call.url, /\/v2\/campaigns\/123\/orders\/456$/);
assert.ok(!call.url.includes('?'), 'нет query — оба id в path');
});
test('downloadShipmentAct: бинарный ответ → Blob (не JSON-парс)', { skip }, async () => {
const fetchMock = mockFetch({ status: 200, body: new Blob(['PDF-CONTENT']) });
const api = new FbsApi(new Configuration({ apiKey: 'k', fetchApi: fetchMock }));
const blob = await api.downloadShipmentAct(123, 456);
assert.ok(blob instanceof Blob, 'ответ — Blob');
assert.equal(await blob.text(), 'PDF-CONTENT');
const [call] = fetchMock.calls;
assert.match(call.url, /\/v2\/campaigns\/123\/first-mile\/shipments\/456\/act$/);
});
test('сетевая ошибка (fetch reject) → FetchError, не тихий success', { skip }, async () => {
const fetchMock = mockFetch({ reject: new TypeError('fetch failed') });
const api = new CampaignsApi(new Configuration({ apiKey: 'k', fetchApi: fetchMock }));
await assert.rejects(() => api.getCampaigns(1), FetchError);
});
test('middleware: pre мутирует запрос (добавляет заголовок), post видит ответ', { skip }, async () => {
const seen = [];
const fetchMock = mockFetch({ status: 200, body: JSON.stringify({ campaigns: [] }) });
const api = new CampaignsApi(
new Configuration({
apiKey: 'k',
fetchApi: fetchMock,
middleware: [
{
pre: async ({ url, init }) => ({ url, init: { ...init, headers: { ...init.headers, 'X-Trace': 'abc' } } }),
post: async ({ response }) => {
seen.push(response.status);
return response;
},
},
],
})
);
await api.getCampaigns(1);
const [call] = fetchMock.calls;
assert.equal(call.init.headers['X-Trace'], 'abc', 'pre-заголовок дошёл до fetch');
assert.deepEqual(seen, [200], 'post вызван с ответом');
});
test('businessId vs campaignId: правильный идентификатор уходит в URL', { skip }, async () => {
const fetchMock = mockFetch({ status: 200, body: JSON.stringify({ orders: [] }) });
const api = new OrdersApi(new Configuration({ apiKey: 'k', fetchApi: fetchMock }));
await api.getBusinessOrders(987, { orderIds: new Set([1]) }); // businessId-метод
await api.getOrder(123, 456); // campaignId-метод
const [biz, camp] = fetchMock.calls;
assert.match(biz.url, /\/v1\/businesses\/987\/orders$/, 'businessId в URL бизнес-метода');
assert.match(camp.url, /\/v2\/campaigns\/123\/orders\/456$/, 'campaignId в URL кампаний');
});

57
test/response-error.test.js Executable file
View File

@@ -0,0 +1,57 @@
// Юнит-тесты сгенерированного клиента (publish-model.md — обязательный гейт):
// mock fetch через Configuration.fetchApi (typescript-fetch runtime):
// - URL/заголовки запроса (Api-Key из конфига, query-параметры)
// - 2xx → распарсенные данные
// - 4xx/5xx → ResponseError (ответ в err.response)
// Клиент импортируется из скомпилированного dist (после стадии build).
// Расположение dist — через client-path.js (dev-репо и пакет живут на разной глубине).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { clientDir, hasDist } = require('./client-path');
const { Configuration, CampaignsApi, ResponseError } = hasDist ? require(clientDir) : {};
const skipNoDist = hasDist ? false : 'dist не собран — запустите стадию build (worker-пайплайн)';
function mockFetch({ status, body, headers = {} }) {
const calls = [];
const fn = async (url, init) => {
calls.push({ url, init });
return new Response(body, { status, headers });
};
fn.calls = calls;
return fn;
}
test('getCampaigns: 2xx → данные; запрос несёт Api-Key и query', { skip: skipNoDist }, async () => {
const fetchMock = mockFetch({ status: 200, body: JSON.stringify({ campaigns: [{ id: 1, title: 'Магазин' }] }) });
const api = new CampaignsApi(new Configuration({ apiKey: 'test-key', fetchApi: fetchMock }));
const res = await api.getCampaigns('token-1', 10);
assert.equal(res.campaigns.length, 1);
const [call] = fetchMock.calls;
assert.match(call.url, /\/campaigns\?pageToken=token-1&limit=10$/);
assert.equal(call.init.headers['Api-Key'], 'test-key');
});
test('getCampaigns: 4xx → ResponseError, статус и тело доступны', { skip: skipNoDist }, async () => {
const fetchMock = mockFetch({ status: 403, body: JSON.stringify({ code: 'FORBIDDEN', message: 'нет доступа' }) });
const api = new CampaignsApi(new Configuration({ apiKey: 'test-key', fetchApi: fetchMock }));
let caught = null;
try {
await api.getCampaigns(1);
} catch (err) {
caught = err;
}
assert.ok(caught instanceof ResponseError, 'ожидался ResponseError');
assert.equal(caught.response.status, 403);
const body = await caught.response.json();
assert.equal(body.code, 'FORBIDDEN');
});
test('getCampaigns: 5xx → ResponseError', { skip: skipNoDist }, async () => {
const fetchMock = mockFetch({ status: 500, body: 'oops' });
const api = new CampaignsApi(new Configuration({ apiKey: 'k', fetchApi: fetchMock }));
await assert.rejects(() => api.getCampaigns(1), ResponseError);
});