Skip to content
Open
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
238 changes: 238 additions & 0 deletions src/__tests__/subscribe.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Subscribe command tests
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { buildSubscribeCommands, formatPlansTable, formatPromoCode, formatSubscriptionsTable } from '../commands/subscribe.js';
import { NansenError, ErrorCode } from '../api.js';

Check failure on line 7 in src/__tests__/subscribe.test.js

View workflow job for this annotation

GitHub Actions / lint

'ErrorCode' is defined but never used. Allowed unused vars must match /^_/u

Check failure on line 7 in src/__tests__/subscribe.test.js

View workflow job for this annotation

GitHub Actions / lint

'NansenError' is defined but never used. Allowed unused vars must match /^_/u

// ── Helpers ──

function mockApi(overrides = {}) {
return {
apiKey: 'test-key',
baseUrl: 'https://api.nansen.ai',
appBaseUrl: 'https://app.nansen.ai',
defaultHeaders: {},
getApiPlans: vi.fn(),
validatePromoCode: vi.fn(),
createStripeSubscription: vi.fn(),
createCoinbaseSubscription: vi.fn(),
createMoonpaySubscription: vi.fn(),
getActiveSubscriptions: vi.fn(),
cancelSubscription: vi.fn(),
...overrides,
};
}

// ── Tests ──

describe('subscribe command', () => {
let log, cmd;

afterEach(() => { vi.restoreAllMocks(); });

beforeEach(() => {
log = vi.fn();
cmd = buildSubscribeCommands({ log })['subscribe'];
});

// ── Help output ──

describe('help output', () => {
it('shows top-level help when no subcommand', async () => {
await cmd([], mockApi(), {}, {});
expect(log).toHaveBeenCalledWith(expect.stringContaining('SUBCOMMANDS'));
});

it('shows help for "help" subcommand', async () => {
await cmd(['help'], mockApi(), {}, {});
expect(log).toHaveBeenCalledWith(expect.stringContaining('SUBCOMMANDS'));
});

it('shows plans help with --help flag', async () => {
await cmd(['plans'], mockApi(), { help: true }, {});
expect(log).toHaveBeenCalledWith(expect.stringContaining('List available API plans'));
});

it('shows create help with --help flag', async () => {
await cmd(['create'], mockApi(), { help: true }, {});
expect(log).toHaveBeenCalledWith(expect.stringContaining('--price-id'));
});
});

// ── Plans ──

describe('plans', () => {
it('calls getApiPlans and returns result', async () => {
const plans = [{ name: 'Pro', price: 9900, currency: 'usd', interval: 'month', priceId: 'price_123' }];
const api = mockApi({ getApiPlans: vi.fn().mockResolvedValue(plans) });
const result = await cmd(['plans'], api, {}, {});
expect(api.getApiPlans).toHaveBeenCalled();
expect(result).toEqual(plans);
});
});

// ── Promo code ──

describe('promo-code', () => {
it('validates a promo code', async () => {
const promo = { code: 'SAVE20', percentOff: 20 };
const api = mockApi({ validatePromoCode: vi.fn().mockResolvedValue(promo) });
const result = await cmd(['promo-code', 'SAVE20'], api, {}, {});
expect(api.validatePromoCode).toHaveBeenCalledWith('SAVE20');
expect(result).toEqual(promo);
});

it('throws when code is missing', async () => {
await expect(cmd(['promo-code'], mockApi(), {}, {}))
.rejects.toThrow('Required: <code>');
});
});

// ── Create ──

describe('create', () => {
it('creates a stripe subscription by default', async () => {
const resp = { id: 'sub_1', url: 'https://checkout.stripe.com/...' };
const api = mockApi({ createStripeSubscription: vi.fn().mockResolvedValue(resp) });
const result = await cmd(['create'], api, {}, { 'price-id': 'price_123', 'promo-code': 'SAVE20' });
expect(api.createStripeSubscription).toHaveBeenCalledWith({
priceId: 'price_123',
promotionCode: 'SAVE20',
paymentMethodId: undefined,
});
expect(result).toEqual(resp);
});

it('creates a coinbase subscription', async () => {
const resp = { id: 'sub_2' };
const api = mockApi({ createCoinbaseSubscription: vi.fn().mockResolvedValue(resp) });
const result = await cmd(['create'], api, {}, { 'price-id': 'price_123', provider: 'coinbase' });
expect(api.createCoinbaseSubscription).toHaveBeenCalledWith({
priceId: 'price_123',
promotionCode: undefined,
});
expect(result).toEqual(resp);
});

it('creates a moonpay subscription', async () => {
const resp = { id: 'sub_3' };
const api = mockApi({ createMoonpaySubscription: vi.fn().mockResolvedValue(resp) });
const result = await cmd(['create'], api, {}, { 'price-id': 'price_123', provider: 'moonpay' });
expect(api.createMoonpaySubscription).toHaveBeenCalledWith({
priceId: 'price_123',
promotionCode: undefined,
});
expect(result).toEqual(resp);
});

it('throws when --price-id is missing', async () => {
await expect(cmd(['create'], mockApi(), {}, {}))
.rejects.toThrow('Required: --price-id');
});

it('throws for unknown provider', async () => {
await expect(cmd(['create'], mockApi(), {}, { 'price-id': 'price_123', provider: 'paypal' }))
.rejects.toThrow('Unknown provider');
});

it('passes --payment-method for stripe', async () => {
const api = mockApi({ createStripeSubscription: vi.fn().mockResolvedValue({}) });
await cmd(['create'], api, {}, { 'price-id': 'price_123', 'payment-method': 'pm_abc' });
expect(api.createStripeSubscription).toHaveBeenCalledWith({
priceId: 'price_123',
promotionCode: undefined,
paymentMethodId: 'pm_abc',
});
});
});

// ── Cancel ──

describe('cancel', () => {
it('calls cancelSubscription', async () => {
const api = mockApi({ cancelSubscription: vi.fn().mockResolvedValue({ success: true }) });
const result = await cmd(['cancel'], api, {}, {});
expect(api.cancelSubscription).toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});

// ── Status ──

describe('status', () => {
it('calls getActiveSubscriptions', async () => {
const subs = [{ id: 'sub_1', status: 'active', provider: 'stripe' }];
const api = mockApi({ getActiveSubscriptions: vi.fn().mockResolvedValue(subs) });
const result = await cmd(['status'], api, {}, {});
expect(api.getActiveSubscriptions).toHaveBeenCalled();
expect(result).toEqual(subs);
});
});

// ── Unknown subcommand ──

it('throws for unknown subcommand', async () => {
await expect(cmd(['bogus'], mockApi(), {}, {}))
.rejects.toThrow('Unknown subscribe subcommand');
});
});

// ── Formatting ──

describe('formatPlansTable', () => {
it('formats plans as a table', () => {
const plans = [
{ name: 'Pro', price: 9900, currency: 'usd', interval: 'month', priceId: 'price_123' },
{ name: 'Enterprise', price: 49900, currency: 'usd', interval: 'year', priceId: 'price_456' },
];
const table = formatPlansTable(plans);
expect(table).toContain('NAME');
expect(table).toContain('PRICE');
expect(table).toContain('Pro');
expect(table).toContain('99.00 USD');
expect(table).toContain('Enterprise');
});

it('returns "No plans available" for empty array', () => {
expect(formatPlansTable([])).toBe('No plans available');
});
});

describe('formatPromoCode', () => {
it('formats percent off', () => {
const result = formatPromoCode({ code: 'SAVE20', percentOff: 20 });
expect(result).toContain('SAVE20');
expect(result).toContain('20%');
});

it('formats amount off', () => {
const result = formatPromoCode({ code: 'FLAT10', amountOff: 1000, currency: 'usd' });
expect(result).toContain('10.00 USD');
});

it('formats firstTimeTransaction', () => {
const result = formatPromoCode({ code: 'NEW', percentOff: 10, firstTimeTransaction: true });
expect(result).toContain('First-time only: yes');
});

it('returns fallback for null', () => {
expect(formatPromoCode(null)).toBe('Invalid promo code');
});
});

describe('formatSubscriptionsTable', () => {
it('formats subscriptions as a table', () => {
const subs = [{ id: 'sub_1', status: 'active', provider: 'stripe' }];
const table = formatSubscriptionsTable(subs);
expect(table).toContain('ID');
expect(table).toContain('STATUS');
expect(table).toContain('sub_1');
expect(table).toContain('active');
});

it('returns "No active subscriptions" for empty array', () => {
expect(formatSubscriptionsTable([])).toBe('No active subscriptions');
});
});
55 changes: 55 additions & 0 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ function loadConfig() {
config.baseUrl = process.env.NANSEN_BASE_URL;
}

// Nansen app backend (subscription endpoints)
config.appBaseUrl = process.env.NANSEN_APP_URL || 'https://app.nansen.ai';

return config;
}

Expand Down Expand Up @@ -420,6 +423,7 @@ export class NansenAPI {
constructor(apiKey = config.apiKey, baseUrl = config.baseUrl, options = {}) {
this.apiKey = apiKey || null;
this.baseUrl = baseUrl;
this.appBaseUrl = options.appBaseUrl || config.appBaseUrl;
this.retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options.retry };
this.cacheOptions = {
enabled: options.cache?.enabled ?? false,
Expand Down Expand Up @@ -1361,6 +1365,57 @@ export class NansenAPI {
async alertsDelete(alertId) {
return this.request(`/api/v1/smart-alert/${encodeURIComponent(alertId)}`, {}, { method: 'DELETE' });
}

// ============= Subscription Endpoints =============

/**
* Make a request to the Nansen app backend (app.nansen.ai).
* Reuses apiKey auth but targets appBaseUrl instead of baseUrl.
*/
async appRequest(endpoint, body = {}, options = {}) {
const savedBaseUrl = this.baseUrl;
this.baseUrl = this.appBaseUrl;
try {
return await this.request(endpoint, body, options);
} finally {
this.baseUrl = savedBaseUrl;
}
}

async getApiPlans() {
return this.appRequest('/plans/api', {}, { method: 'GET' });
}

async validatePromoCode(code) {
return this.appRequest(`/promo-code/${encodeURIComponent(code)}`, {}, { method: 'GET' });
}

async createStripeSubscription({ priceId, promotionCode, paymentMethodId }) {
const body = { priceId };
if (promotionCode) body.promotionCode = promotionCode;
if (paymentMethodId) body.paymentMethodId = paymentMethodId;
return this.appRequest('/subscription/stripe', body);
}

async createCoinbaseSubscription({ priceId, promotionCode }) {
const body = { priceId };
if (promotionCode) body.promotionCode = promotionCode;
return this.appRequest('/subscription/coinbase', body);
}

async createMoonpaySubscription({ priceId, promotionCode }) {
const body = { priceId };
if (promotionCode) body.promotionCode = promotionCode;
return this.appRequest('/subscription/moonpay', body);
}

async getActiveSubscriptions() {
return this.appRequest('/subscription/recurring', {}, { method: 'GET' });
}

async cancelSubscription() {
return this.appRequest('/subscription/recurring', {}, { method: 'DELETE' });
}
}

export default NansenAPI;
24 changes: 21 additions & 3 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigF
import { buildWalletCommands } from './wallet.js';
import { buildTradingCommands } from './trading.js';
import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
import { buildSubscribeCommands, formatPlansTable, formatSubscriptionsTable, formatPromoCode } from './commands/subscribe.js';
import { buildAgentCommands } from './commands/agent.js';
import { resolveAddress, isEnsName } from './ens.js';
import fs from 'fs';
Expand Down Expand Up @@ -703,6 +704,7 @@ COMMANDS:
wallet create, list, show, export, default, delete, forget-password
agent Ask the Nansen AI research agent (fast/expert modes)
alerts list, create, update, toggle, delete
subscribe plans, promo-code, create, cancel, status
web search, fetch
account Show API key status, plan, and remaining credits
login Save API key (--api-key <key>, --human, or NANSEN_API_KEY env var)
Expand Down Expand Up @@ -1620,7 +1622,7 @@ export async function runCLI(rawArgs, deps = {}) {
return '';
};

const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...commandOverrides };
const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildSubscribeCommands(deps), ...buildAgentCommands(deps), ...commandOverrides };

if (flags.version || flags.v) {
output(VERSION);
Expand Down Expand Up @@ -1659,7 +1661,7 @@ export async function runCLI(rawArgs, deps = {}) {
}
// First try subcommand help
// Skip for 'trade'/'alerts' — their handlers show their own rich usage
if (command && subcommand && command !== 'trade' && command !== 'alerts' && command !== 'agent') {
if (command && subcommand && command !== 'trade' && command !== 'alerts' && command !== 'subscribe' && command !== 'agent') {
const subHelp = generateSubcommandHelp(command, subcommand);
if (subHelp) {
output(deprecationNote(command) + subHelp);
Expand All @@ -1669,7 +1671,7 @@ export async function runCLI(rawArgs, deps = {}) {
}
// Then try command-level help (list subcommands)
// Skip for 'trade'/'alerts' — let the handler show its own usage
const cmdSchemaLookup = command !== 'trade' && command !== 'alerts' && command !== 'agent' && (SCHEMA.commands[command] || SCHEMA.commands.research.subcommands[command]);
const cmdSchemaLookup = command !== 'trade' && command !== 'alerts' && command !== 'subscribe' && command !== 'agent' && (SCHEMA.commands[command] || SCHEMA.commands.research.subcommands[command]);
if (command && cmdSchemaLookup) {
const cmdSchema = cmdSchemaLookup;
const lines = [`${command} — ${cmdSchema.description}`];
Expand Down Expand Up @@ -1783,6 +1785,22 @@ export async function runCLI(rawArgs, deps = {}) {
return { type: 'success', data: result };
}

// Subscribe table formatting
if (command === 'subscribe' && table) {
if (subcommand === 'plans') {
output(formatPlansTable(Array.isArray(result) ? result : result?.plans ?? result?.data ?? []));
return { type: 'success', data: result };
}
if (subcommand === 'status') {
output(formatSubscriptionsTable(Array.isArray(result) ? result : result?.subscriptions ?? result?.data ?? []));
return { type: 'success', data: result };
}
if (subcommand === 'promo-code') {
output(formatPromoCode(result));
return { type: 'success', data: result };
}
}

// Output in requested format
if (stream) {
// Stream mode: output each record as a JSON line (NDJSON)
Expand Down
Loading
Loading