From 5ef7cc92633164958cd537f2fb68f46a525763ba Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Tue, 17 Feb 2026 21:35:27 -0500 Subject: [PATCH 1/3] feat: add V2 API support, new tools, and comprehensive docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade products and customers endpoints to always use V2 API, which returns richer data (sku, category/tag objects, date_created, additional_emails) while remaining fully backward-compatible with V1 responses. ## V2 API migration - Products: always use V2 with search, category, and tag filtering; normalize both array and numeric-keyed object responses - Customers: always use V2 with date preset and date range filtering; use `&customer=` param for single lookups (accepts ID or email) - Add `buildV2Url()` for V2 endpoint construction - Remove `buildPublicUrl()` (V2 with auth is always used) ## New tools - `edd_get_discount_by_code` — case-insensitive lookup by discount code - `edd_list_active_discounts` — filter to only active discount codes - `edd_get_stats_by_preset` — stats with date presets (today, this_week, this_quarter, this_year, etc.) ## Schema improvements - `ProductInfoSchema`: add `sku`, widen `category`/`tags` to accept V2 object arrays, widen `exp_length` to accept string - `ProductSchema`: add `index` and `attachment_id` to file objects - `ProductsResponseSchema`: accept both array and numeric-keyed object - `CustomerInfoSchema`: make `id` optional (V2 returns `customer_id`), add `additional_emails` and `date_created` - Add input schemas for all new tools with date pair validation ## Resilience - Treat EDD "No X found!" API responses as empty results, not errors - Gracefully handle V2 numeric-keyed product objects (Object.values) - Validate startDate/endDate pairs in customer listing tool ## Documentation - Add docs/API.md with complete tool reference, response formats, date presets, and API version details ## Tests - 40 unit tests covering V2 URL construction, response normalization, date filtering, search/category/tag params, discount by code, active discount filtering, stats presets, and empty result handling Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- docs/API.md | 599 ++++++++++++++++++++++++++++++++++ src/edd-client.ts | 169 +++++++--- src/index.ts | 140 +++++++- src/types.ts | 51 ++- tests/unit/edd-client.test.ts | 271 +++++++++++++-- 5 files changed, 1148 insertions(+), 82 deletions(-) create mode 100644 docs/API.md diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..700d7bf --- /dev/null +++ b/docs/API.md @@ -0,0 +1,599 @@ +# EDD MCP Server API Reference + +Complete API documentation for all MCP tools provided by this server. + +## Table of Contents + +- [Authentication](#authentication) +- [Products](#products) +- [Customers](#customers) +- [Sales](#sales) +- [Discounts](#discounts) +- [Statistics](#statistics) +- [File Download Logs](#file-download-logs) +- [Utility](#utility) +- [Response Formats](#response-formats) +- [Error Handling](#error-handling) + +--- + +## Authentication + +The EDD API uses query parameter authentication: + +- **API Key**: Your public API key +- **API Token**: Your secret API token + +Get credentials from: **WordPress Admin > Downloads > Settings > API** + +Your API URL is typically `https://your-site.com/edd-api/`. + +--- + +## Products + +### edd_list_products + +List products from the EDD store with pricing and stats. Uses the V2 API, which returns richer data including `sku`, category/tag objects, and file metadata. Optionally search by keyword, filter by category slug/ID, or filter by tag slug/ID. Category and tag filters can be combined. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `number` | number | No | Number of products to return (default: all) | +| `search` | string | No | Search keyword to match against product titles and descriptions | +| `category` | string | No | Filter by category slug or ID | +| `tag` | string | No | Filter by tag slug or ID | + +**Response:** +```json +{ + "count": 5, + "products": [ + { + "id": 123, + "title": "Product Name", + "status": "publish", + "sku": "PROD-123", + "pricing": { "amount": "29.00" }, + "licensing": "v1.0" + } + ] +} +``` + +**Example (search):** +```json +{ "search": "wordpress plugin", "number": 10 } +``` + +**Example (category filter):** +```json +{ "category": "plugins" } +``` + +--- + +### edd_get_product + +Get detailed information about a specific product by ID. Returns the full EDD product object including pricing, files, licensing, stats, and categories/tags. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `productId` | number | Yes | The product ID | + +**Response:** +```json +{ + "info": { + "id": 123, + "slug": "product-name", + "title": "Product Name", + "status": "publish", + "sku": "PROD-123", + "category": [{ "term_id": 3, "name": "ebooks", "slug": "ebooks" }], + "tags": [{ "term_id": 7, "name": "pdf", "slug": "pdf" }], + "create_date": "2025-01-01 00:00:00", + "modified_date": "2025-01-15 12:00:00" + }, + "pricing": { "amount": "29.00" }, + "files": [{ "index": "0", "name": "product-v1.0.zip", "file": "https://..." }], + "licensing": { "enabled": true, "version": "1.0" }, + "stats": { "total": { "sales": 50, "earnings": 1450.00 } } +} +``` + +--- + +## Customers + +### edd_list_customers + +List customers with their purchase statistics. Uses the V2 API, which returns richer data including `date_created` and `additional_emails`. Optionally filter by creation date preset or custom date range. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `number` | number | No | Number of customers to return (default: 10) | +| `page` | number | No | Page number for pagination | +| `date` | string | No | Date preset: `today` or `yesterday` | +| `startDate` | string | No | Start date in YYYYMMDD format (requires `endDate`) | +| `endDate` | string | No | End date in YYYYMMDD format (requires `startDate`) | + +**Response:** +```json +{ + "count": 10, + "customers": [ + { + "id": "456", + "userId": "789", + "email": "john@example.com", + "name": "John Doe", + "totalPurchases": 5, + "totalSpent": 245.00, + "totalDownloads": 12 + } + ] +} +``` + +Note: `id` is the EDD customer ID (use with `edd_get_customer`). `userId` is the WordPress user ID when available. + +**Example (today's customers):** +```json +{ "date": "today" } +``` + +**Example (date range):** +```json +{ "startDate": "20250101", "endDate": "20250131" } +``` + +--- + +### edd_get_customer + +Get detailed customer information by EDD customerId or email. Uses V2 API with the `&customer={identifier}` param, which accepts both numeric IDs and email addresses. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `customerId` | number | No | Customer ID to retrieve | +| `email` | string | No | Customer email to retrieve | + +At least one of `customerId` or `email` is required. + +**Response:** +```json +{ + "info": { + "id": "456", + "user_id": "789", + "customer_id": "456", + "email": "john@example.com", + "display_name": "John Doe", + "first_name": "John", + "last_name": "Doe", + "additional_emails": ["john.alt@example.com"], + "date_created": "2024-06-15 10:30:00" + }, + "stats": { + "total_purchases": 5, + "total_spent": 245.00, + "total_downloads": 12 + } +} +``` + +**Example:** +```json +{ "customerId": 456 } +``` + +--- + +## Sales + +### edd_list_sales + +List recent sales/transactions with optional filtering. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `number` | number | No | Number of sales to return (default: 10) | +| `page` | number | No | Page number for pagination | +| `email` | string | No | Filter by customer email | +| `startDate` | string | No | Start date (YYYYMMDD format) | +| `endDate` | string | No | End date (YYYYMMDD format) | + +**Response:** +```json +{ + "count": 10, + "sales": [ + { + "id": 1234, + "email": "customer@example.com", + "total": 53.90, + "date": "2025-01-15 14:30:00", + "gateway": "stripe", + "products": ["Product Name"], + "hasLicenses": true, + "discountCodes": ["SAVE10"] + } + ] +} +``` + +--- + +### edd_get_sale + +Get detailed information about a specific sale by ID or purchase key. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `saleId` | number | No | Sale ID to retrieve | +| `purchaseKey` | string | No | Purchase key to retrieve | + +At least one of `saleId` or `purchaseKey` is required. + +**Response:** +```json +{ + "ID": 1234, + "key": "abc123def456", + "email": "customer@example.com", + "total": 53.90, + "subtotal": 49.00, + "tax": 4.90, + "date": "2025-01-15 14:30:00", + "gateway": "stripe", + "products": [ + { "id": 123, "name": "Product Name", "price": 49.00 } + ], + "licenses": [ + { "key": "license-key-here", "exp_date": "2026-01-15" } + ], + "discountCodes": ["SAVE10"] +} +``` + +**Example:** +```json +{ "saleId": 1234 } +``` + +--- + +## Discounts + +### edd_list_discounts + +List all discount codes with their usage statistics. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `number` | number | No | Number of discounts to return | + +**Response:** +```json +{ + "count": 3, + "discounts": [ + { + "id": 100, + "code": "SUMMER20", + "name": "Summer Sale", + "amount": "20", + "type": "percent", + "uses": 45, + "maxUses": 100, + "status": "active" + } + ] +} +``` + +--- + +### edd_get_discount + +Get detailed information about a specific discount code by ID. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `discountId` | number | Yes | The discount ID | + +**Response:** +```json +{ + "ID": 100, + "name": "Summer Sale", + "code": "SUMMER20", + "amount": "20", + "type": "percent", + "uses": 45, + "max_uses": 100, + "start_date": "2025-06-01", + "exp_date": "2025-08-31", + "status": "active", + "product_requirements": [], + "global_discount": "1", + "single_use": "0" +} +``` + +**Example:** +```json +{ "discountId": 100 } +``` + +--- + +### edd_get_discount_by_code + +Look up a discount by its code string (case-insensitive). Fetches all discounts and filters client-side (EDD API does not support server-side code filtering). + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `code` | string | Yes | The discount code to look up | + +**Response:** Same structure as `edd_get_discount`. + +**Example:** +```json +{ "code": "SUMMER20" } +``` + +--- + +### edd_list_active_discounts + +List only currently active discount codes, filtering out expired and disabled ones. Fetches discounts then filters client-side, so the returned count may be less than `number`. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `number` | number | No | Number of discounts to fetch before filtering | + +**Response:** Same structure as `edd_list_discounts`, but only includes discounts with `status: "active"`. + +--- + +## Statistics + +### edd_get_stats + +Get earnings or sales statistics (current month, last month, and all-time totals). + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `type` | string | Yes | `sales` (count) or `earnings` (revenue) | + +**Response:** +```json +{ + "type": "earnings", + "stats": { + "earnings": { + "current_month": 5890.00, + "last_month": 4750.00, + "totals": 75000.00 + } + } +} +``` + +--- + +### edd_get_stats_by_date + +Get daily earnings or sales statistics for a custom date range. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `type` | string | Yes | `sales` or `earnings` | +| `startDate` | string | Yes | Start date in YYYYMMDD format | +| `endDate` | string | Yes | End date in YYYYMMDD format | + +**Response:** +```json +{ + "type": "earnings", + "startDate": "20250101", + "endDate": "20250103", + "total": 450, + "daily": { + "2025-01-01": 100, + "2025-01-02": 150, + "2025-01-03": 200 + } +} +``` + +--- + +### edd_get_stats_by_product + +Get earnings or sales statistics broken down by product. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `type` | string | Yes | `sales` or `earnings` | +| `productId` | number | No | Specific product ID (omit for all) | + +--- + +### edd_get_stats_by_preset + +Get earnings or sales statistics using a preset date filter. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `type` | string | Yes | `sales` or `earnings` | +| `date` | string | Yes | Date preset (see below) | + +**Date Presets:** +- `today`, `yesterday` +- `this_week`, `last_week` +- `this_month`, `last_month` +- `this_quarter`, `last_quarter` +- `this_year`, `last_year` + +--- + +## File Download Logs + +### edd_get_download_logs + +Get file download history, optionally filtered by product or customer. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `number` | number | No | Number of logs to return (default: 10) | +| `productId` | number | No | Filter by product ID | +| `customerId` | number | No | Filter by customer ID | + +**Response:** +```json +{ + "count": 5, + "logs": [ + { + "id": 5000, + "productId": 123, + "productName": "Product Name", + "fileName": "product-v1.0.zip", + "date": "2025-01-20 15:45:00", + "paymentId": 1234 + } + ] +} +``` + +--- + +## Utility + +### edd_validate_connection + +Validate Store API URL and credentials by making lightweight requests. + +**Parameters:** None + +**Response:** +```json +{ + "ok": true, + "checks": { + "productsEndpoint": { "ok": true, "sampleCount": 1 }, + "authenticatedEndpoint": { "ok": true, "sampleCount": 1 } + } +} +``` + +--- + +## Response Formats + +All tool responses return JSON text content. Successful responses include a `count` field and the relevant data array. Error responses include descriptive messages with troubleshooting hints. + +--- + +## Error Handling + +### HTTP Errors + +The server provides detailed diagnostic information including: +- HTTP status code and URL +- Response headers (content-type, server) +- Body snippet for debugging +- Actionable hints (e.g., Cloudflare detection, wrong URL format) + +### Common Error Codes + +| Code | Meaning | Solution | +|------|---------|----------| +| 401 | Unauthorized | Check API key and token | +| 403 | Forbidden | User lacks permissions | +| 404 | Not Found | API URL is likely wrong (should end with `/edd-api/`) | +| 500 | Server Error | Check EDD logs on server | + +### Retry Logic + +The server automatically retries failed requests up to 3 times with exponential backoff (1s, 2s, 4s). + +--- + +## API Versions + +### V1 + +Used by: sales, discounts, stats, download logs. + +### V2 + +Used by products and customers endpoints for richer response data: +- **Products** (`edd_list_products`, `edd_get_product`): SKU field, category/tag term objects, file index/attachment_id. Supports search, category, and tag filtering. +- **Customers** (`edd_list_customers`, `edd_get_customer`): `date_created`, `additional_emails` fields. Supports date preset and date range filtering. Single customer lookup via `&customer={id_or_email}`. + +V2 endpoints use the `/edd-api/v2/` path. + +--- + +## Pagination + +List endpoints support pagination via: +- **number**: Results per page (default varies by tool) +- **page**: Page number (default: 1) + +Use `number: -1` to retrieve all results (use with caution on large datasets). + +--- + +## Date Formats + +### YYYYMMDD Format + +Used for date range queries: +- `20250101` = January 1, 2025 +- `20251231` = December 31, 2025 + +### Date Presets + +**Statistics** (`edd_get_stats_by_preset`): +- `today`, `yesterday` +- `this_week`, `last_week` +- `this_month`, `last_month` +- `this_quarter`, `last_quarter` +- `this_year`, `last_year` + +**Customers** (`edd_list_customers`): +- `today`, `yesterday` diff --git a/src/edd-client.ts b/src/edd-client.ts index d2ea371..2ac360c 100644 --- a/src/edd-client.ts +++ b/src/edd-client.ts @@ -141,21 +141,6 @@ export class EDDClient { return url.toString(); } - /** - * Build public URL (no auth required, e.g., products endpoint). - */ - private buildPublicUrl(endpoint: string, params: Record = {}): string { - const url = new URL(endpoint, this.apiUrl); - - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - url.searchParams.set(key, String(value)); - } - } - - return url.toString(); - } - /** * Make HTTP request with retry logic. */ @@ -194,7 +179,8 @@ export class EDDClient { const data = (await response.json()) as T & { error?: string }; // Check for API-level errors - if (data.error) { + // "No X found!" messages are not real errors — just empty results + if (data.error && !/^No \w+ found!?$/i.test(data.error)) { throw new Error(`EDD API Error: ${data.error}`); } @@ -213,16 +199,37 @@ export class EDDClient { } // =========================================================================== - // Products Endpoints (Public - no auth required) + // Products Endpoints (V2 API) // =========================================================================== /** - * List all products. + * List products. Always uses V2 API for richer response data (sku, category/tag + * objects, file index/attachment_id). Supports search, category, and tag filtering. + * Note: V2 products endpoint doesn't require auth for public products, but + * including auth doesn't hurt and allows access to non-public products. */ - async listProducts(options: { number?: number; product?: number } = {}): Promise { - const url = this.buildPublicUrl('products/', options); + async listProducts(options: { + number?: number; + product?: number; + search?: string; + category?: string; + tag?: string; + } = {}): Promise { + const params: Record = { + number: options.number, + product: options.product, + }; + if (options.search) params.s = options.search; + if (options.category) params.category = options.category; + if (options.tag) params.tag = options.tag; + + const url = this.buildV2Url('products/', params); const response = await this.request(url); - return response.products; + // V2 may return products as a numeric-keyed object instead of an array + const raw = response.products; + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === 'object') return Object.values(raw); + return []; } /** @@ -233,6 +240,28 @@ export class EDDClient { return products[0] || null; } + /** + * Build a V2 API URL. Inserts `v2/` before the endpoint in the API path. + */ + private buildV2Url(endpoint: string, params: Record = {}): string { + // apiUrl is like https://example.com/edd-api/ + // We need https://example.com/edd-api/v2/{endpoint}/ + const base = this.apiUrl.replace(/\/$/, ''); + const v2Base = `${base}/v2/`; + const url = new URL(endpoint, v2Base); + + url.searchParams.set('key', this.apiKey); + url.searchParams.set('token', this.apiToken); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + return url.toString(); + } + // =========================================================================== // Sales Endpoints (Authenticated) // =========================================================================== @@ -278,46 +307,53 @@ export class EDDClient { } // =========================================================================== - // Customers Endpoints (Authenticated) + // Customers Endpoints (V2 API) // =========================================================================== /** - * List customers with pagination. + * List customers with pagination. Always uses V2 API for richer response data + * (date_created, additional_emails). Optionally filter by date preset or date range. */ - async listCustomers(options: { number?: number; page?: number } = {}): Promise { - const url = this.buildUrl('customers/', options); + async listCustomers(options: { + number?: number; + page?: number; + date?: string; + startdate?: string; + enddate?: string; + } = {}): Promise { + const params: Record = { + number: options.number, + page: options.page, + }; + + if (options.startdate && options.enddate) { + params.date = 'range'; + params.startdate = options.startdate; + params.enddate = options.enddate; + } else if (options.date) { + params.date = options.date; + } + + const url = this.buildV2Url('customers/', params); const response = await this.request(url); return response.customers || []; } /** - * Get a customer by ID. + * Get a customer by ID or email (V2 API). + * Uses the V2 `&customer={identifier}` param which accepts both IDs and emails. */ async getCustomerById(customerId: number): Promise { - // EDD API can be inconsistent about which ID appears in list responses. - // Prefer the `customer` query param, but fall back to other common variants. - const candidates: Array> = [ - { customer: customerId }, - { id: customerId }, - { user_id: customerId }, - { user: customerId }, - ]; - - for (const params of candidates) { - const url = this.buildUrl('customers/', params); - const response = await this.request(url); - const found = response.customers?.[0] || null; - if (found) return found; - } - - return null; + const url = this.buildV2Url('customers/', { customer: customerId }); + const response = await this.request(url); + return response.customers?.[0] || null; } /** - * Get a customer by email. + * Get a customer by email (V2 API). */ async getCustomerByEmail(email: string): Promise { - const url = this.buildUrl('customers/', { email }); + const url = this.buildV2Url('customers/', { customer: email }); const response = await this.request(url); return response.customers?.[0] || null; } @@ -445,6 +481,25 @@ export class EDDClient { return results; } + /** + * Get stats with a preset date filter (today, yesterday, this_week, etc.). + */ + async getStatsByPreset( + type: 'sales' | 'earnings', + date: string + ): Promise { + const url = this.buildUrl('stats/', { type, date }); + const response = await this.request>(url); + + if ('stats' in response && response.stats) { + return response.stats as StatsResponse['stats']; + } + + return { + [type]: response[type], + } as StatsResponse['stats']; + } + // =========================================================================== // Discounts Endpoints (Authenticated) // =========================================================================== @@ -467,6 +522,30 @@ export class EDDClient { return response.discounts?.[0] || null; } + /** + * Get a discount by its code (client-side filter). + * Note: The EDD API does not support server-side filtering by code, + * so this fetches all discounts and filters locally. + */ + async getDiscountByCode(code: string): Promise { + const discounts = await this.listDiscounts({ number: -1 }); + const match = discounts.find( + (d) => d.code.toLowerCase() === code.toLowerCase() + ); + return match || null; + } + + /** + * List only active discounts (client-side filter). + * Note: Fetches `number` discounts then filters to active ones, so the + * returned count may be less than requested. Use `number: -1` to fetch + * all discounts first if you need an exact count of active discounts. + */ + async listActiveDiscounts(options: { number?: number } = {}): Promise { + const discounts = await this.listDiscounts(options); + return discounts.filter((d) => d.status === 'active'); + } + // =========================================================================== // Download Logs Endpoints (Authenticated) // =========================================================================== diff --git a/src/index.ts b/src/index.ts index ec9d34c..86bfa99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,18 +31,25 @@ server.registerTool( 'edd_list_products', { title: 'List EDD Products', - description: 'List all products from the Easy Digital Downloads store with pricing and stats', + description: + 'List products from the Easy Digital Downloads store with pricing, stats, and SKU. ' + + 'Optionally search by keyword, filter by category slug/ID, or filter by tag slug/ID. ' + + 'Category and tag filters can be combined.', inputSchema: { number: z.number().optional().describe('Number of products to return (default: all)'), + search: z.string().optional().describe('Search keyword to match against product titles and descriptions'), + category: z.string().optional().describe('Filter by category slug or ID'), + tag: z.string().optional().describe('Filter by tag slug or ID'), }, }, - async ({ number }) => { - const products = await edd.listProducts({ number }); + async ({ number, search, category, tag }) => { + const products = await edd.listProducts({ number, search, category, tag }); const summary = products.map((p) => ({ id: p.info.id, title: p.info.title, status: p.info.status, + sku: p.info.sku ?? null, pricing: p.pricing, licensing: p.licensing?.enabled ? `v${p.licensing.version}` : null, })); @@ -196,23 +203,41 @@ server.registerTool( 'edd_list_customers', { title: 'List EDD Customers', - description: 'List customers with their purchase statistics', + description: + 'List customers with their purchase statistics (date_created, additional_emails included). ' + + 'Optionally filter by creation date preset (today/yesterday) or a custom date range in YYYYMMDD format.', inputSchema: { number: z.number().optional().describe('Number of customers to return (default: 10)'), page: z.number().optional().describe('Page number for pagination'), + date: z.enum(['today', 'yesterday']).optional().describe('Filter by creation date preset'), + startDate: z.string().optional().describe('Start date in YYYYMMDD format (requires endDate)'), + endDate: z.string().optional().describe('End date in YYYYMMDD format (requires startDate)'), }, }, - async ({ number, page }) => { - const customers = await edd.listCustomers({ number: number ?? 10, page }); + async ({ number, page, date, startDate, endDate }) => { + if ((startDate && !endDate) || (!startDate && endDate)) { + return { + content: [{ type: 'text', text: 'Error: Both startDate and endDate are required when using date range filtering' }], + }; + } + + const customers = await edd.listCustomers({ + number: number ?? 10, + page, + date, + startdate: startDate, + enddate: endDate, + }); const summary = customers.map((c) => ({ // `id` is the EDD customer ID to use with edd_get_customer(customerId). id: c.info.customer_id ?? c.info.id, userId: c.info.user_id ?? null, email: c.info.email, - name: c.info.display_name || `${c.info.first_name} ${c.info.last_name}`.trim(), + name: c.info.display_name || `${c.info.first_name ?? ''} ${c.info.last_name ?? ''}`.trim(), totalPurchases: c.stats.total_purchases, totalSpent: c.stats.total_spent, + totalDownloads: c.stats.total_downloads, })); return { @@ -234,7 +259,7 @@ server.registerTool( { title: 'Get EDD Customer', description: - 'Get detailed customer information by EDD customerId (preferred) or email. If you only have a WordPress userId, try it as customerId (fallbacks applied).', + 'Get detailed customer information by EDD customerId or email. Returns full customer data including date_created and additional_emails.', inputSchema: { customerId: z.number().optional().describe('Customer ID to retrieve'), email: z.string().optional().describe('Customer email to retrieve'), @@ -518,6 +543,105 @@ server.registerTool( } ); +// ============================================================================ +// Tool 14: Get Discount by Code +// ============================================================================ +server.registerTool( + 'edd_get_discount_by_code', + { + title: 'Get EDD Discount by Code', + description: 'Look up a discount by its code string (case-insensitive)', + inputSchema: { + code: z.string().describe('The discount code to look up (case-insensitive)'), + }, + }, + async ({ code }) => { + const discount = await edd.getDiscountByCode(code); + + if (!discount) { + return { + content: [{ type: 'text', text: `Discount with code "${code}" not found` }], + }; + } + + return { + content: [{ type: 'text', text: JSON.stringify(discount, null, 2) }], + }; + } +); + +// ============================================================================ +// Tool 16: List Active Discounts +// ============================================================================ +server.registerTool( + 'edd_list_active_discounts', + { + title: 'List Active EDD Discounts', + description: 'List only currently active discount codes, filtering out expired and disabled ones', + inputSchema: { + number: z.number().optional().describe('Number of discounts to return'), + }, + }, + async ({ number }) => { + const discounts = await edd.listActiveDiscounts({ number }); + + const summary = discounts.map((d) => ({ + id: d.ID, + code: d.code, + name: d.name, + amount: d.amount, + type: d.type, + uses: d.uses, + maxUses: d.max_uses, + startDate: d.start_date, + expDate: d.exp_date, + })); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ count: discounts.length, discounts: summary }, null, 2), + }, + ], + }; + } +); + +// ============================================================================ +// Tool 17: Get Stats by Preset +// ============================================================================ +server.registerTool( + 'edd_get_stats_by_preset', + { + title: 'Get EDD Stats by Date Preset', + description: + 'Get earnings or sales statistics using a preset date filter (today, yesterday, this_week, this_month, etc.)', + inputSchema: { + type: z.enum(['sales', 'earnings']).describe('Type of stats: sales (count) or earnings (revenue)'), + date: z.enum([ + 'today', 'yesterday', + 'this_week', 'last_week', + 'this_month', 'last_month', + 'this_quarter', 'last_quarter', + 'this_year', 'last_year', + ]).describe('Predefined date filter'), + }, + }, + async ({ type, date }) => { + const stats = await edd.getStatsByPreset(type, date); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ type, date, stats }, null, 2), + }, + ], + }; + } +); + // ============================================================================ // Start Server // ============================================================================ diff --git a/src/types.ts b/src/types.ts index c7e8fac..77787b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,8 +17,11 @@ export const ProductInfoSchema = z.object({ content: z.string().optional(), excerpt: z.string().optional(), thumbnail: z.union([z.string(), z.boolean()]).optional(), - category: z.union([z.string(), z.union([z.boolean(), z.array(z.string())])]).optional(), - tags: z.union([z.string(), z.union([z.boolean(), z.array(z.string())])]).optional(), + // V1 returns strings/arrays of strings; V2 returns arrays of objects or false + category: z.union([z.string(), z.boolean(), z.array(z.union([z.string(), z.record(z.string(), z.unknown())]))]).optional(), + tags: z.union([z.string(), z.boolean(), z.array(z.union([z.string(), z.record(z.string(), z.unknown())]))]).optional(), + // V2 field + sku: z.string().optional(), }); export const ProductPricingSchema = z.record(z.string(), z.string()); @@ -27,7 +30,7 @@ export const ProductLicensingSchema = z.object({ enabled: z.boolean(), version: z.string().optional(), exp_unit: z.string().optional(), - exp_length: z.number().optional(), + exp_length: z.union([z.number(), z.string()]).optional(), }); export const ProductSchema = z.object({ @@ -56,6 +59,9 @@ export const ProductSchema = z.object({ name: z.string(), file: z.string(), condition: z.union([z.string(), z.number()]), + // V2 fields + index: z.union([z.string(), z.number()]).optional(), + attachment_id: z.union([z.string(), z.number()]).optional(), }) ) .optional(), @@ -63,13 +69,15 @@ export const ProductSchema = z.object({ }); export const ProductsResponseSchema = z.object({ - products: z.array(ProductSchema), + // V2 may return products as a numeric-keyed object or an array + products: z.union([z.array(ProductSchema), z.record(z.string(), ProductSchema)]), request_speed: z.number().optional(), }); // Customer types export const CustomerInfoSchema = z.object({ - id: z.string(), + // V1 returns `id`, V2 returns `customer_id` — both optional since either may be present + id: z.string().optional(), user_id: z.string().optional(), username: z.string().optional(), display_name: z.string().optional(), @@ -77,6 +85,9 @@ export const CustomerInfoSchema = z.object({ first_name: z.string().optional(), last_name: z.string().optional(), email: z.string(), + // V2 fields + additional_emails: z.array(z.string()).optional(), + date_created: z.string().optional(), }); export const CustomerStatsSchema = z.object({ @@ -227,7 +238,10 @@ export type DownloadLogsResponse = z.infer; export const ListProductsInputSchema = z.object({ product: z.number().optional().describe('Specific product ID to retrieve'), - number: z.number().optional().describe('Number of products to return (default: 10)'), + number: z.number().optional().describe('Number of products to return (default: all)'), + search: z.string().optional().describe('Search keyword to match against product titles and descriptions'), + category: z.string().optional().describe('Filter by category slug or ID'), + tag: z.string().optional().describe('Filter by tag slug or ID'), }); export const GetProductInputSchema = z.object({ @@ -250,6 +264,9 @@ export const GetSaleInputSchema = z.object({ export const ListCustomersInputSchema = z.object({ number: z.number().optional().describe('Number of customers to return (default: 10)'), page: z.number().optional().describe('Page number for pagination'), + date: z.enum(['today', 'yesterday']).optional().describe('Filter by creation date preset'), + startDate: z.string().optional().describe('Start date in YYYYMMDD format (requires endDate)'), + endDate: z.string().optional().describe('End date in YYYYMMDD format (requires startDate)'), }); export const GetCustomerInputSchema = z.object({ @@ -289,3 +306,25 @@ export const GetDownloadLogsInputSchema = z.object({ productId: z.number().optional().describe('Filter by product ID'), customerId: z.number().optional().describe('Filter by customer ID'), }); + +// V2 API Input Schemas + +export const GetDiscountByCodeInputSchema = z.object({ + code: z.string().describe('The discount code to look up (case-insensitive)'), +}); + +export const ListActiveDiscountsInputSchema = z.object({ + number: z.number().optional().describe('Number of discounts to return'), +}); + +export const GetStatsByPresetInputSchema = z.object({ + type: z.enum(['sales', 'earnings']).describe('Type of stats: sales (count) or earnings (revenue)'), + date: z.enum([ + 'today', 'yesterday', + 'this_week', 'last_week', + 'this_month', 'last_month', + 'this_quarter', 'last_quarter', + 'this_year', 'last_year', + ]).describe('Predefined date filter'), +}); + diff --git a/tests/unit/edd-client.test.ts b/tests/unit/edd-client.test.ts index 90f9e62..909f2ec 100644 --- a/tests/unit/edd-client.test.ts +++ b/tests/unit/edd-client.test.ts @@ -40,7 +40,7 @@ describe('EDDClient', () => { ); }); - it('should build public URLs without auth for products', async () => { + it('should use V2 URL with auth for products', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ products: [] }), @@ -49,14 +49,15 @@ describe('EDDClient', () => { await client.listProducts({ number: 3 }); const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain('products/'); - expect(calledUrl).not.toContain('key='); - expect(calledUrl).not.toContain('token='); + expect(calledUrl).toContain('/v2/products/'); + expect(calledUrl).toContain('key=test-key'); + expect(calledUrl).toContain('token=test-token'); + expect(calledUrl).toContain('number=3'); }); }); describe('listProducts', () => { - it('should return products array', async () => { + it('should return products array when API returns array', async () => { const mockProducts = [ { info: { id: 1, slug: 'test', title: 'Test Product', create_date: '', modified_date: '', status: 'publish' }, @@ -73,6 +74,29 @@ describe('EDDClient', () => { expect(products).toHaveLength(1); expect(products[0].info.title).toBe('Test Product'); }); + + it('should normalize numeric-keyed object to array (V2 behavior)', async () => { + const mockProducts = { + '1': { + info: { id: 42, slug: 'product-a', title: 'Product A', create_date: '', modified_date: '', status: 'publish' }, + pricing: { amount: '10.00' }, + }, + '2': { + info: { id: 43, slug: 'product-b', title: 'Product B', create_date: '', modified_date: '', status: 'publish' }, + pricing: { amount: '20.00' }, + }, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ products: mockProducts }), + }); + + const products = await client.listProducts(); + + expect(products).toHaveLength(2); + expect(products[0].info.title).toBe('Product A'); + expect(products[1].info.title).toBe('Product B'); + }); }); describe('getProduct', () => { @@ -150,10 +174,10 @@ describe('EDDClient', () => { }); describe('listCustomers', () => { - it('should return customers with pagination', async () => { + it('should use V2 URL and return customers with pagination', async () => { const mockCustomers = [ { - info: { id: '1', email: 'customer@test.com' }, + info: { id: '1', email: 'customer@test.com', date_created: '2025-01-01 10:00:00', additional_emails: [] }, stats: { total_purchases: 3, total_spent: 500, total_downloads: 10 }, }, ]; @@ -164,38 +188,66 @@ describe('EDDClient', () => { const customers = await client.listCustomers({ number: 10, page: 1 }); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/customers/'); expect(customers).toHaveLength(1); expect(customers[0].stats.total_spent).toBe(500); }); + + it('should include date param for date preset filtering', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ customers: [] }), + }); + + await client.listCustomers({ date: 'today' }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/customers/'); + expect(calledUrl).toContain('date=today'); + }); + + it('should include date range params', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ customers: [] }), + }); + + await client.listCustomers({ startdate: '20250101', enddate: '20250131' }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/customers/'); + expect(calledUrl).toContain('date=range'); + expect(calledUrl).toContain('startdate=20250101'); + expect(calledUrl).toContain('enddate=20250131'); + }); }); describe('getCustomerById', () => { - it('should fall back when customer param returns empty', async () => { + it('should use V2 customer param to find by ID', async () => { + // V2 returns customer_id, not id const mockCustomer = { - info: { id: '7673', user_id: '7673', customer_id: '6316', email: 'found@example.com' }, + info: { customer_id: '1', email: 'found@example.com', date_created: '2025-01-01' }, stats: { total_purchases: 5, total_spent: 1000 }, }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ customers: [mockCustomer] }), + }); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ customers: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ customers: [mockCustomer] }), - }); - - const customer = await client.getCustomerById(7673); + const customer = await client.getCustomerById(1); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/customers/'); + expect(calledUrl).toContain('customer=1'); expect(customer?.info.email).toBe('found@example.com'); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('customer=7673'), expect.any(Object)); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('id=7673'), expect.any(Object)); + expect(customer?.info.customer_id).toBe('1'); + expect(mockFetch).toHaveBeenCalledTimes(1); }); }); describe('getCustomerByEmail', () => { - it('should find customer by email', async () => { + it('should use V2 customer param to find by email', async () => { const mockCustomer = { info: { id: '42', email: 'found@example.com', display_name: 'Found User' }, stats: { total_purchases: 5, total_spent: 1000 }, @@ -207,6 +259,9 @@ describe('EDDClient', () => { const customer = await client.getCustomerByEmail('found@example.com'); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/customers/'); + expect(calledUrl).toContain('customer=found%40example.com'); expect(customer?.info.email).toBe('found@example.com'); }); }); @@ -368,6 +423,17 @@ describe('EDDClient', () => { await expect(client.listCustomers()).rejects.toThrow('EDD API Error: Invalid API key'); }); + it('should treat "No X found!" as empty results, not errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ error: 'No customers found!' }), + }); + + const customers = await client.listCustomers(); + + expect(customers).toEqual([]); + }); + it('should include helpful hints for 404 HTML responses', async () => { mockFetch .mockResolvedValueOnce({ @@ -439,4 +505,163 @@ describe('EDDClient', () => { expect(mockFetch).toHaveBeenCalledTimes(3); }, 10000); }); + + // =========================================================================== + // V2 API Tests + // =========================================================================== + + describe('listProducts with filtering', () => { + it('should include search param mapped to s', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ products: [] }), + }); + + await client.listProducts({ search: 'wordpress plugin' }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/products/'); + expect(calledUrl).toContain('s=wordpress+plugin'); + }); + + it('should return matching products from search', async () => { + const mockProducts = [ + { + info: { id: 1, slug: 'wp-fusion', title: 'WP Fusion', create_date: '', modified_date: '', status: 'publish' }, + pricing: { amount: '297.00' }, + }, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ products: mockProducts }), + }); + + const products = await client.listProducts({ search: 'fusion' }); + + expect(products).toHaveLength(1); + expect(products[0].info.title).toBe('WP Fusion'); + }); + + it('should include category param', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ products: [] }), + }); + + await client.listProducts({ category: 'plugins' }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/products/'); + expect(calledUrl).toContain('category=plugins'); + }); + + it('should include tag param', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ products: [] }), + }); + + await client.listProducts({ tag: 'premium' }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/products/'); + expect(calledUrl).toContain('tag=premium'); + }); + + it('should combine category and tag params', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ products: [] }), + }); + + await client.listProducts({ category: 'ebooks', tag: 'pdf' }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/products/'); + expect(calledUrl).toContain('category=ebooks'); + expect(calledUrl).toContain('tag=pdf'); + }); + + it('should pass product param for single product lookup', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ products: [] }), + }); + + await client.listProducts({ product: 55 }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/v2/products/'); + expect(calledUrl).toContain('product=55'); + }); + }); + + + describe('getDiscountByCode', () => { + it('should find discount by code (case-insensitive)', async () => { + const mockDiscounts = [ + { ID: 1, name: 'Holiday Sale', code: 'HOLIDAY25', amount: '25', type: 'percent', uses: 50, status: 'active' }, + { ID: 2, name: 'Summer Sale', code: 'SUMMER10', amount: '10', type: 'percent', uses: 20, status: 'active' }, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ discounts: mockDiscounts }), + }); + + const discount = await client.getDiscountByCode('holiday25'); + + expect(discount).not.toBeNull(); + expect(discount?.code).toBe('HOLIDAY25'); + }); + + it('should return null when code not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ discounts: [] }), + }); + + const discount = await client.getDiscountByCode('NONEXISTENT'); + + expect(discount).toBeNull(); + }); + }); + + describe('listActiveDiscounts', () => { + it('should filter to only active discounts', async () => { + const mockDiscounts = [ + { ID: 1, name: 'Active', code: 'ACTIVE', amount: '10', type: 'percent', uses: 5, status: 'active' }, + { ID: 2, name: 'Expired', code: 'EXPIRED', amount: '20', type: 'percent', uses: 100, status: 'expired' }, + { ID: 3, name: 'Also Active', code: 'ACTIVE2', amount: '15', type: 'flat', uses: 2, status: 'active' }, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ discounts: mockDiscounts }), + }); + + const discounts = await client.listActiveDiscounts(); + + expect(discounts).toHaveLength(2); + expect(discounts.every((d) => d.status === 'active')).toBe(true); + }); + }); + + describe('getStatsByPreset', () => { + it('should pass date preset to stats endpoint', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + stats: { + earnings: { current_month: 5000, totals: 100000 }, + }, + }), + }); + + const stats = await client.getStatsByPreset('earnings', 'this_month'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('date=this_month'); + expect(calledUrl).toContain('type=earnings'); + expect(stats.earnings?.current_month).toBe(5000); + }); + }); }); From 4c178034295d5f71691a53027a2df4e4f425f170 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Tue, 17 Feb 2026 21:49:33 -0500 Subject: [PATCH 2/3] fix: address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix retry docs: clarify "3 total attempts" not "retries 3 times" - Fix tool numbering: 14 → 15 → 16 (was skipping 15) - Move buildV2Url above first call site for readability - Consolidate getStatsByPreset into getStats(type, date?) with shared normalizeStatsResponse helper to eliminate duplication - Widen "No X found!" regex to handle multi-word entities like "No download logs found!" via [\w\s]+ - Add test for multi-word empty-result messages (41 tests total) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- docs/API.md | 2 +- src/edd-client.ts | 85 +++++++++++++++-------------------- src/index.ts | 6 +-- tests/unit/edd-client.test.ts | 15 ++++++- 4 files changed, 54 insertions(+), 54 deletions(-) diff --git a/docs/API.md b/docs/API.md index 700d7bf..2090acd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -548,7 +548,7 @@ The server provides detailed diagnostic information including: ### Retry Logic -The server automatically retries failed requests up to 3 times with exponential backoff (1s, 2s, 4s). +The server makes up to 3 total attempts (1 initial + 2 retries) with exponential backoff (1s, 2s, 4s). --- diff --git a/src/edd-client.ts b/src/edd-client.ts index 2ac360c..3738fb4 100644 --- a/src/edd-client.ts +++ b/src/edd-client.ts @@ -180,7 +180,8 @@ export class EDDClient { // Check for API-level errors // "No X found!" messages are not real errors — just empty results - if (data.error && !/^No \w+ found!?$/i.test(data.error)) { + // Use [\w\s]+ to match multi-word entities like "download logs" + if (data.error && !/^No [\w\s]+ found!?$/i.test(data.error)) { throw new Error(`EDD API Error: ${data.error}`); } @@ -198,6 +199,28 @@ export class EDDClient { throw lastError || new Error('Request failed after retries'); } + /** + * Build a V2 API URL. Inserts `v2/` before the endpoint in the API path. + */ + private buildV2Url(endpoint: string, params: Record = {}): string { + // apiUrl is like https://example.com/edd-api/ + // We need https://example.com/edd-api/v2/{endpoint}/ + const base = this.apiUrl.replace(/\/$/, ''); + const v2Base = `${base}/v2/`; + const url = new URL(endpoint, v2Base); + + url.searchParams.set('key', this.apiKey); + url.searchParams.set('token', this.apiToken); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + return url.toString(); + } + // =========================================================================== // Products Endpoints (V2 API) // =========================================================================== @@ -240,28 +263,6 @@ export class EDDClient { return products[0] || null; } - /** - * Build a V2 API URL. Inserts `v2/` before the endpoint in the API path. - */ - private buildV2Url(endpoint: string, params: Record = {}): string { - // apiUrl is like https://example.com/edd-api/ - // We need https://example.com/edd-api/v2/{endpoint}/ - const base = this.apiUrl.replace(/\/$/, ''); - const v2Base = `${base}/v2/`; - const url = new URL(endpoint, v2Base); - - url.searchParams.set('key', this.apiKey); - url.searchParams.set('token', this.apiToken); - - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - url.searchParams.set(key, String(value)); - } - } - - return url.toString(); - } - // =========================================================================== // Sales Endpoints (Authenticated) // =========================================================================== @@ -364,22 +365,15 @@ export class EDDClient { /** * Get general stats (current month, last month, totals). + * Optionally pass a date preset (today, yesterday, this_week, etc.) to filter. */ - async getStats(type: 'sales' | 'earnings'): Promise { - const url = this.buildUrl('stats/', { type }); - // API returns stats directly without wrapper + async getStats(type: 'sales' | 'earnings', date?: string): Promise { + const params: Record = { type }; + if (date) params.date = date; + const url = this.buildUrl('stats/', params); const response = await this.request>(url); - // Handle both response formats - if ('stats' in response && response.stats) { - return response.stats as StatsResponse['stats']; - } - - // Direct response format: { earnings: {...}, request_speed: ... } - // or { sales: {...}, request_speed: ... } - return { - [type]: response[type], - } as StatsResponse['stats']; + return this.normalizeStatsResponse(response, type); } /** @@ -482,22 +476,17 @@ export class EDDClient { } /** - * Get stats with a preset date filter (today, yesterday, this_week, etc.). + * Normalize the varied stats response formats from the EDD API. */ - async getStatsByPreset( - type: 'sales' | 'earnings', - date: string - ): Promise { - const url = this.buildUrl('stats/', { type, date }); - const response = await this.request>(url); - + private normalizeStatsResponse( + response: Record, + type: 'sales' | 'earnings' + ): StatsResponse['stats'] { if ('stats' in response && response.stats) { return response.stats as StatsResponse['stats']; } - - return { - [type]: response[type], - } as StatsResponse['stats']; + // Direct response format: { earnings: {...}, request_speed: ... } + return { [type]: response[type] } as StatsResponse['stats']; } // =========================================================================== diff --git a/src/index.ts b/src/index.ts index 86bfa99..4395067 100644 --- a/src/index.ts +++ b/src/index.ts @@ -571,7 +571,7 @@ server.registerTool( ); // ============================================================================ -// Tool 16: List Active Discounts +// Tool 15: List Active Discounts // ============================================================================ server.registerTool( 'edd_list_active_discounts', @@ -609,7 +609,7 @@ server.registerTool( ); // ============================================================================ -// Tool 17: Get Stats by Preset +// Tool 16: Get Stats by Preset // ============================================================================ server.registerTool( 'edd_get_stats_by_preset', @@ -629,7 +629,7 @@ server.registerTool( }, }, async ({ type, date }) => { - const stats = await edd.getStatsByPreset(type, date); + const stats = await edd.getStats(type, date); return { content: [ diff --git a/tests/unit/edd-client.test.ts b/tests/unit/edd-client.test.ts index 909f2ec..c4aa449 100644 --- a/tests/unit/edd-client.test.ts +++ b/tests/unit/edd-client.test.ts @@ -434,6 +434,17 @@ describe('EDDClient', () => { expect(customers).toEqual([]); }); + it('should treat multi-word "No X found!" as empty results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ error: 'No download logs found!' }), + }); + + const response = await client.getDownloadLogs(); + + expect(response).toEqual([]); + }); + it('should include helpful hints for 404 HTML responses', async () => { mockFetch .mockResolvedValueOnce({ @@ -645,7 +656,7 @@ describe('EDDClient', () => { }); }); - describe('getStatsByPreset', () => { + describe('getStats with date preset', () => { it('should pass date preset to stats endpoint', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -656,7 +667,7 @@ describe('EDDClient', () => { }), }); - const stats = await client.getStatsByPreset('earnings', 'this_month'); + const stats = await client.getStats('earnings', 'this_month'); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain('date=this_month'); From 7ea9e296fd2a8ff530c23c09735a10562843ac94 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Tue, 17 Feb 2026 22:02:52 -0500 Subject: [PATCH 3/3] fix: address second round of CodeRabbit feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix backoff docs: only 2 delays occur (1s, 2s), not 3 — third attempt either succeeds or throws - Remove dead stats wrapper check from normalizeStatsResponse — EDD API returns stats directly ({ earnings: {...} }), never wrapped - Update test mocks to match real EDD API response format Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- docs/API.md | 2 +- src/edd-client.ts | 10 +++++----- tests/unit/edd-client.test.ts | 15 ++++++--------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/docs/API.md b/docs/API.md index 2090acd..813a039 100644 --- a/docs/API.md +++ b/docs/API.md @@ -548,7 +548,7 @@ The server provides detailed diagnostic information including: ### Retry Logic -The server makes up to 3 total attempts (1 initial + 2 retries) with exponential backoff (1s, 2s, 4s). +The server makes up to 3 total attempts (1 initial + 2 retries) with exponential backoff delays of 1s and 2s between attempts. --- diff --git a/src/edd-client.ts b/src/edd-client.ts index 3738fb4..cd7a1c5 100644 --- a/src/edd-client.ts +++ b/src/edd-client.ts @@ -190,7 +190,7 @@ export class EDDClient { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < retries) { - // Exponential backoff: 1s, 2s, 4s + // Exponential backoff: 1s, 2s await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt - 1) * 1000)); } } @@ -478,14 +478,14 @@ export class EDDClient { /** * Normalize the varied stats response formats from the EDD API. */ + /** + * Normalize the direct stats response format from the EDD API. + * EDD returns `{ earnings: {...} }` or `{ sales: {...} }` directly. + */ private normalizeStatsResponse( response: Record, type: 'sales' | 'earnings' ): StatsResponse['stats'] { - if ('stats' in response && response.stats) { - return response.stats as StatsResponse['stats']; - } - // Direct response format: { earnings: {...}, request_speed: ... } return { [type]: response[type] } as StatsResponse['stats']; } diff --git a/tests/unit/edd-client.test.ts b/tests/unit/edd-client.test.ts index c4aa449..f4d17c3 100644 --- a/tests/unit/edd-client.test.ts +++ b/tests/unit/edd-client.test.ts @@ -271,9 +271,8 @@ describe('EDDClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ - stats: { - earnings: { current_month: 5000, last_month: 4500, totals: 100000 }, - }, + earnings: { current_month: 5000, last_month: 4500, totals: 100000 }, + request_speed: 0.05, }), }); @@ -287,9 +286,8 @@ describe('EDDClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ - stats: { - sales: { current_month: 50, last_month: 45, totals: 1000 }, - }, + sales: { current_month: 50, last_month: 45, totals: 1000 }, + request_speed: 0.03, }), }); @@ -661,9 +659,8 @@ describe('EDDClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ - stats: { - earnings: { current_month: 5000, totals: 100000 }, - }, + earnings: { current_month: 5000, totals: 100000 }, + request_speed: 0.04, }), });