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
114 changes: 87 additions & 27 deletions packages/backend/src/adapters/de/weclapp.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
{
"slug": "weclapp",
"name": "weclapp Cloud ERP",
"description": "Access weclapp Cloud ERP data including customers, orders, invoices, articles, and warehouse management. Popular ERP for German SMBs.",
"description": "Access weclapp Cloud ERP data including parties (customers/suppliers), sales orders, invoices, and articles. Popular ERP for German SMBs.",
"region": "de",
"category": "erp",
"icon": "weclapp",
"docsUrl": "https://www.weclapp.com/api2/",
"docsUrl": "https://www.weclapp.com/api/",
"requiredEnvVars": [
"WECLAPP_TENANT",
"WECLAPP_API_TOKEN"
],
"connector": {
"name": "weclapp ERP",
"type": "REST",
"baseUrl": "https://{{WECLAPP_TENANT}}.weclapp.com/webapp/api/v1",
"baseUrl": "https://{{WECLAPP_TENANT}}.weclapp.com/webapp/api/v2",
"authType": "API_KEY",
"authConfig": {
"headerName": "AuthenticationToken",
Expand All @@ -23,62 +23,89 @@
"tools": [
{
"name": "weclapp_list_customers",
"description": "List customers from weclapp ERP. Returns customer names, IDs, contact info, and customer numbers.",
"description": "List parties (customers/suppliers/contacts) from weclapp ERP. In API v2 customers and suppliers are unified under /party — pass filter='customerNumber-notnull=true' to restrict to customers, or filter='supplierNumber-notnull=true' for suppliers. Use `properties` to limit returned fields (e.g. 'id,customerNumber,company,firstName,lastName,email') to reduce payload size.",
"parameters": {
"type": "object",
"properties": {
"page": {
"type": "number",
"description": "Page number (default: 1)"
"description": "Page number (1-based)"
},
"pageSize": {
"type": "number",
"description": "Results per page (default: 25, max: 100)"
"description": "Results per page (default: 100, max: 1000)"
},
"properties": {
"type": "string",
"description": "Comma-separated list of fields to return, e.g. 'id,customerNumber,company,firstName,lastName,email'"
},
"filter": {
"type": "string",
"description": "weclapp v2 filter expression, e.g. 'customerNumber-notnull=true' or 'partyType-eq=ORGANIZATION'"
},
"sort": {
"type": "string",
"description": "Sort field, prefix with '-' for descending (e.g. '-createdDate')"
}
}
},
"endpointMapping": {
"method": "GET",
"path": "/customer",
"path": "/party",
"queryParams": {
"page": "$page",
"pageSize": "$pageSize"
"pageSize": "$pageSize",
"properties": "$properties",
"filter": "$filter",
"sort": "$sort"
}
}
},
{
"name": "weclapp_get_customer",
"description": "Get detailed information about a specific customer by ID.",
"description": "Get a specific party (customer/supplier/contact) by ID. In API v2 this maps to /party/id/{id}.",
"parameters": {
"type": "object",
"properties": {
"customerId": {
"partyId": {
"type": "string",
"description": "The weclapp customer ID"
"description": "The weclapp party ID (numeric string)"
}
},
"required": [
"customerId"
"partyId"
]
},
"endpointMapping": {
"method": "GET",
"path": "/customer/id/{customerId}"
"path": "/party/id/{partyId}"
}
},
{
"name": "weclapp_list_sales_orders",
"description": "List sales orders from weclapp ERP. Returns order numbers, customer info, amounts, and status.",
"description": "List sales orders from weclapp ERP. Returns order numbers, customer info, amounts, and status. Use `properties` to limit fields and `filter` to narrow results (e.g. 'orderNumber-eq=SO-1042').",
"parameters": {
"type": "object",
"properties": {
"page": {
"type": "number",
"description": "Page number (default: 1)"
"description": "Page number (1-based)"
},
"pageSize": {
"type": "number",
"description": "Results per page (default: 25, max: 100)"
"description": "Results per page (default: 100, max: 1000)"
},
"properties": {
"type": "string",
"description": "Comma-separated list of fields to return"
},
"filter": {
"type": "string",
"description": "weclapp v2 filter expression (e.g. 'status-eq=ORDER_ENTRY_IN_PROGRESS')"
},
"sort": {
"type": "string",
"description": "Sort field, prefix with '-' for descending"
}
}
},
Expand All @@ -87,23 +114,38 @@
"path": "/salesOrder",
"queryParams": {
"page": "$page",
"pageSize": "$pageSize"
"pageSize": "$pageSize",
"properties": "$properties",
"filter": "$filter",
"sort": "$sort"
}
}
},
{
"name": "weclapp_list_invoices",
"description": "List invoices from weclapp ERP. Returns invoice numbers, amounts, due dates, and payment status.",
"description": "List sales invoices from weclapp ERP. Returns invoice numbers, amounts, due dates, and payment status. Use `filter` for queries like 'paid-eq=false' or 'invoiceDate-gt=2026-01-01'.",
"parameters": {
"type": "object",
"properties": {
"page": {
"type": "number",
"description": "Page number (default: 1)"
"description": "Page number (1-based)"
},
"pageSize": {
"type": "number",
"description": "Results per page (default: 25, max: 100)"
"description": "Results per page (default: 100, max: 1000)"
},
"properties": {
"type": "string",
"description": "Comma-separated list of fields to return"
},
"filter": {
"type": "string",
"description": "weclapp v2 filter expression"
},
"sort": {
"type": "string",
"description": "Sort field, prefix with '-' for descending"
}
}
},
Expand All @@ -112,23 +154,38 @@
"path": "/salesInvoice",
"queryParams": {
"page": "$page",
"pageSize": "$pageSize"
"pageSize": "$pageSize",
"properties": "$properties",
"filter": "$filter",
"sort": "$sort"
}
}
},
{
"name": "weclapp_list_articles",
"description": "List articles (products) from weclapp ERP. Returns article numbers, names, prices, and stock levels.",
"description": "List articles (products) from weclapp ERP. Returns article numbers, names, prices, and stock info. Use `filter` like 'articleNumber-eq=ABC123' for SKU lookups.",
"parameters": {
"type": "object",
"properties": {
"page": {
"type": "number",
"description": "Page number (default: 1)"
"description": "Page number (1-based)"
},
"pageSize": {
"type": "number",
"description": "Results per page (default: 25, max: 100)"
"description": "Results per page (default: 100, max: 1000)"
},
"properties": {
"type": "string",
"description": "Comma-separated list of fields to return"
},
"filter": {
"type": "string",
"description": "weclapp v2 filter expression"
},
"sort": {
"type": "string",
"description": "Sort field, prefix with '-' for descending"
}
}
},
Expand All @@ -137,19 +194,22 @@
"path": "/article",
"queryParams": {
"page": "$page",
"pageSize": "$pageSize"
"pageSize": "$pageSize",
"properties": "$properties",
"filter": "$filter",
"sort": "$sort"
}
}
},
{
"name": "weclapp_get_article",
"description": "Get detailed information about a specific article (product) by ID, including stock, pricing, and warehouse data.",
"description": "Get a specific article (product) by ID, including stock, pricing, and warehouse data.",
"parameters": {
"type": "object",
"properties": {
"articleId": {
"type": "string",
"description": "The weclapp article ID"
"description": "The weclapp article ID (numeric string)"
}
},
"required": [
Expand Down
112 changes: 112 additions & 0 deletions packages/backend/src/adapters/de/weclapp.live.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as adapter from './weclapp.json';
import { RestEngine } from '../../connectors/engines/rest.engine';
import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';

/**
* Two layers of verification for the weclapp adapter:
*
* 1. Static — always runs. Asserts the adapter is on API v2 and that the
* paths/auth match the official OpenAPI v2 spec at
* https://www.weclapp.com/api/openapi_v2.yaml. Catches the most common
* failure mode: someone pinning back to v1 (legacy) or re-introducing the
* `/customer` endpoint that v2 replaced with `/party`.
*
* 2. Live — skipped in CI. Hits weclapp's edge against a non-existent tenant
* to prove the URL pattern resolves to weclapp infrastructure (DNS +
* Akamai + ALB + `server: weclapp`). A real end-to-end test needs a tenant
* with a valid token; weclapp does not offer a public sandbox.
*
* Run live with: RUN_WECLAPP_LIVE=1 npx jest src/adapters/de/weclapp.live.spec.ts
*/

describe('weclapp adapter — static spec conformance', () => {
const a = adapter as unknown as {
connector: { baseUrl: string; authType: string; authConfig: Record<string, string> };
tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>;
};

it('uses API v2 (v1 is legacy and being deprecated)', () => {
expect(a.connector.baseUrl).toContain('/webapp/api/v2');
expect(a.connector.baseUrl).not.toContain('/webapp/api/v1');
});

it('authenticates via the AuthenticationToken header (weclapp-specific, not Authorization)', () => {
expect(a.connector.authType).toBe('API_KEY');
expect(a.connector.authConfig.headerName).toBe('AuthenticationToken');
});

it('does not reference /customer (renamed to /party in v2)', () => {
for (const tool of a.tools) {
expect(tool.endpointMapping.path).not.toMatch(/^\/customer(\/|$)/);
}
});

it('uses the /party, /salesOrder, /salesInvoice, /article paths from the v2 spec', () => {
const paths = a.tools.map((t) => t.endpointMapping.path);
expect(paths).toContain('/party');
expect(paths).toContain('/party/id/{partyId}');
expect(paths).toContain('/salesOrder');
expect(paths).toContain('/salesInvoice');
expect(paths).toContain('/article');
expect(paths).toContain('/article/id/{articleId}');
});
});

const maybe = process.env.RUN_WECLAPP_LIVE ? describe : describe.skip;

maybe('weclapp adapter — live edge reachability', () => {
const oauth = {} as unknown as OAuth2TokenService;
const engine = new RestEngine(oauth);

// Bogus tenant: weclapp uses wildcard DNS (*.weclapp.com → Akamai → ALB),
// so the request reaches weclapp's edge but the ALB returns 404 because
// no tenant matches. That 404 with `server: weclapp` proves the baseUrl
// resolves to weclapp infrastructure.
const baseUrl = 'https://anythingmcp-smoke-test-tenant.weclapp.com/webapp/api/v2';

it('reaches weclapp edge (404 from server: weclapp) when calling /party with a bogus tenant', async () => {
let err: any;
try {
await engine.execute(
{
baseUrl,
authType: 'API_KEY',
authConfig: { headerName: 'AuthenticationToken', apiKey: 'bogus-token-for-test' },
},
{ method: 'GET', path: '/party', queryParams: { pageSize: '$pageSize' } },
{ pageSize: 1 },
);
} catch (e) {
err = e;
}
expect(err).toBeDefined();
expect(err.response?.status).toBe(404);
// The `server` response header proves we hit weclapp's edge, not a generic
// CDN/error. If this ever fails, the baseUrl pattern probably changed.
expect(String(err.response?.headers?.server || '').toLowerCase()).toContain('weclapp');
}, 30000);

it('AuthenticationToken header is actually injected by RestEngine', async () => {
// Round-trip the engine and inspect the outgoing request via the AxiosError.
// The error config carries the headers we sent — proves the API_KEY branch
// wrote to `AuthenticationToken`, not `X-API-Key` or `Authorization`.
let err: any;
try {
await engine.execute(
{
baseUrl,
authType: 'API_KEY',
authConfig: { headerName: 'AuthenticationToken', apiKey: 'sentinel-token-12345' },
},
{ method: 'GET', path: '/party' },
{},
);
} catch (e) {
err = e;
}
expect(err).toBeDefined();
const sentHeaders = err.config?.headers || {};
expect(sentHeaders.AuthenticationToken).toBe('sentinel-token-12345');
expect(sentHeaders.Authorization).toBeUndefined();
}, 30000);
});
Loading