/**
* Tests for negotiate_terms tool
* Covers: profile fetching, negotiation, discounts, AdminAPI, session management
*/
import type { GatewayConfig, NegotiationResult } from '../../src/types.js';
import { CheckoutSessionManager } from '../../src/ucp/checkout-session.js';
// ─── Mocks ───
vi.mock('../../src/utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const mockGenerateProfile = vi.fn();
vi.mock('../../src/ucp/profile.js', () => ({
generateProfile: (...args: unknown[]) => mockGenerateProfile(...args),
}));
const mockNegotiateCapabilities = vi.fn();
vi.mock('../../src/ucp/negotiate.js', () => ({
negotiateCapabilities: (...args: unknown[]) => mockNegotiateCapabilities(...args),
}));
const mockGetDiscountCodes = vi.fn();
vi.mock('../../src/shopify/admin.js', () => ({
AdminAPI: vi.fn(() => ({ getDiscountCodes: mockGetDiscountCodes })),
}));
vi.mock('../../src/shopify/client.js', () => ({
ShopifyClient: vi.fn(),
}));
import {
negotiateTerms,
setSessionManager,
resetAdminAPICache,
} from '../../src/tools/negotiate-terms.js';
// ─── Test Fixtures ───
function makeConfig(overrides: Partial<GatewayConfig['shopify']> = {}): GatewayConfig {
return {
shopify: {
apiKey: 'key',
apiSecret: 'secret',
storeDomain: 'test.myshopify.com',
accessToken: 'token',
storefrontToken: 'sf-token',
...overrides,
},
ap2: { signingPrivateKey: 'pk' },
gateway: { baseUrl: 'http://localhost:3000', feeRate: 0.005, feeWalletAddress: 'wallet' },
dynamodb: { mandatesTable: 't1', ledgerTable: 't2', sessionsTable: 't3' },
logLevel: 'info',
};
}
function makeNegotiationResult(overrides: Partial<NegotiationResult> = {}): NegotiationResult {
return {
active_capabilities: [
{ name: 'dev.ucp.shopping.checkout', version: '2026-01-11' },
],
available_discounts: [
{ code: 'AGENT10', type: 'percentage', value: 10, description: '10% off' },
],
shipping_options: [
{ id: 'standard', name: 'Standard', price: 599, currency: 'USD', estimated_days_min: 5, estimated_days_max: 7 },
],
payment_handlers: [{ id: 'shopify_payments', type: 'processor_tokenizer' as const }],
...overrides,
};
}
const validAgentProfile = {
version: '2026-01-11',
services: [
{
type: 'dev.ucp.shopping',
capabilities: [{ name: 'dev.ucp.shopping.checkout' }],
},
],
};
const serverProfile = {
version: '2026-01-11',
services: [
{
type: 'dev.ucp.shopping',
capabilities: [{ name: 'dev.ucp.shopping.checkout', version: '2026-01-11' }],
payment_handlers: [{ id: 'shopify_payments', type: 'processor_tokenizer' }],
},
],
};
// ─── Test Suites ───
describe('negotiateTerms', () => {
let sessionManager: CheckoutSessionManager;
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
resetAdminAPICache();
// Fresh session manager for each test
sessionManager = new CheckoutSessionManager();
setSessionManager(sessionManager);
// Default: generateProfile returns a profile with one service
mockGenerateProfile.mockReturnValue(serverProfile);
// Default: negotiateCapabilities returns a valid result
mockNegotiateCapabilities.mockReturnValue(makeNegotiationResult());
});
// 1. Full happy path
it('completes full negotiation happy path', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
const result = await negotiateTerms(
{ cart_id: 'cart-1', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
expect(result.negotiation).toBeDefined();
expect(result.negotiation.active_capabilities).toHaveLength(1);
expect(result.checkout_session).toBeDefined();
expect(result.checkout_session!.id).toBeDefined();
expect(result.discount_applied).toBe(false);
expect(result.error).toBeUndefined();
});
// 2. Agent profile fetch fails (non-200)
it('uses empty capabilities when agent profile fetch returns non-200', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('Not Found', { status: 404 }),
);
const result = await negotiateTerms(
{ cart_id: 'cart-2', agent_profile_url: 'https://agent.example.com/missing' },
makeConfig(),
);
// Should still succeed — negotiation uses empty agent capabilities
expect(result.negotiation).toBeDefined();
expect(result.error).toBeUndefined();
// negotiateCapabilities should be called with empty capabilities agent
expect(mockNegotiateCapabilities).toHaveBeenCalledWith(
expect.objectContaining({ capabilities: [] }),
expect.anything(),
expect.anything(),
);
});
// 3. Agent profile has invalid structure
it('uses empty capabilities when agent profile has invalid structure', async () => {
// Missing version and services
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ foo: 'bar' }), { status: 200 }),
);
const result = await negotiateTerms(
{ cart_id: 'cart-3', agent_profile_url: 'https://agent.example.com/bad' },
makeConfig(),
);
expect(result.negotiation).toBeDefined();
expect(mockNegotiateCapabilities).toHaveBeenCalledWith(
expect.objectContaining({ capabilities: [] }),
expect.anything(),
expect.anything(),
);
});
// 4. Agent profile URL uses unsupported protocol (ftp:)
it('uses empty capabilities when profile URL has unsupported protocol', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('', { status: 200 }),
);
const result = await negotiateTerms(
{ cart_id: 'cart-4', agent_profile_url: 'ftp://agent.example.com/profile' },
makeConfig(),
);
// fetch should NOT be called for ftp: URLs
expect(fetchSpy).not.toHaveBeenCalled();
expect(result.negotiation).toBeDefined();
expect(mockNegotiateCapabilities).toHaveBeenCalledWith(
expect.objectContaining({ capabilities: [] }),
expect.anything(),
expect.anything(),
);
});
// 5. Server has no services
it('returns error when server has no services', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
mockGenerateProfile.mockReturnValue({ version: '2026-01-11', services: [] });
const result = await negotiateTerms(
{ cart_id: 'cart-5', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
expect(result.error).toBe('Server has no configured services');
expect(result.negotiation.active_capabilities).toEqual([]);
expect(result.checkout_session).toBeUndefined();
});
// 6. Discount code matches available discount
it('sets discount_applied=true when discount code matches', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
const result = await negotiateTerms(
{
cart_id: 'cart-6',
agent_profile_url: 'https://agent.example.com/profile',
discount_code: 'AGENT10',
},
makeConfig(),
);
expect(result.discount_applied).toBe(true);
});
// 7. Discount code doesn't match
it('sets discount_applied=false when discount code does not match', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
const result = await negotiateTerms(
{
cart_id: 'cart-7',
agent_profile_url: 'https://agent.example.com/profile',
discount_code: 'INVALID_CODE',
},
makeConfig(),
);
expect(result.discount_applied).toBe(false);
});
// 8. Discount code case-insensitive match
it('matches discount code case-insensitively', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
const result = await negotiateTerms(
{
cart_id: 'cart-8',
agent_profile_url: 'https://agent.example.com/profile',
discount_code: 'agent10',
},
makeConfig(),
);
expect(result.discount_applied).toBe(true);
});
// 9. AdminAPI configured — fetches and merges real discounts
it('fetches and merges real discounts when AdminAPI is configured', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
mockGetDiscountCodes.mockResolvedValue([
{ code: 'SHOPIFY20', type: 'percentage', value: 20, description: '20% off from Shopify' },
]);
const result = await negotiateTerms(
{ cart_id: 'cart-9', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
// The negotiation result should now include the merged discount
expect(result.negotiation.available_discounts).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'SHOPIFY20' }),
]),
);
});
// 10. AdminAPI configured but getDiscountCodes fails
it('continues with existing discounts when AdminAPI getDiscountCodes fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
mockGetDiscountCodes.mockRejectedValue(new Error('API error'));
const result = await negotiateTerms(
{ cart_id: 'cart-10', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
// Should still succeed — just without additional discounts
expect(result.negotiation).toBeDefined();
expect(result.error).toBeUndefined();
});
// 11. AdminAPI merges discounts avoiding duplicates
it('avoids duplicate discount codes when merging from AdminAPI', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
// Return a discount with the same code as the default (AGENT10)
mockGetDiscountCodes.mockResolvedValue([
{ code: 'AGENT10', type: 'percentage', value: 15, description: 'Duplicate from Shopify' },
{ code: 'NEW20', type: 'fixed_amount', value: 2000, description: '$20 off' },
]);
const result = await negotiateTerms(
{ cart_id: 'cart-11', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
// AGENT10 should appear only once (from negotiation defaults, not duplicated)
const agent10Discounts = result.negotiation.available_discounts.filter(
(d) => d.code.toUpperCase() === 'AGENT10',
);
expect(agent10Discounts).toHaveLength(1);
// NEW20 should be added
expect(result.negotiation.available_discounts).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'NEW20' }),
]),
);
});
// 12. AdminAPI not configured (no storeDomain)
it('skips AdminAPI when storeDomain is not configured', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
const config = makeConfig({ storeDomain: '', accessToken: '' });
const result = await negotiateTerms(
{ cart_id: 'cart-12', agent_profile_url: 'https://agent.example.com/profile' },
config,
);
expect(mockGetDiscountCodes).not.toHaveBeenCalled();
expect(result.negotiation).toBeDefined();
});
// 13. Existing session retrieved instead of creating new one
it('retrieves existing session instead of creating a new one', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
// Pre-create a session and store its ID as the cart_id
const existingSession = sessionManager.create();
const result = await negotiateTerms(
{
cart_id: existingSession.id,
agent_profile_url: 'https://agent.example.com/profile',
},
makeConfig(),
);
expect(result.checkout_session!.id).toBe(existingSession.id);
});
// 14. Network error during profile fetch
it('handles network error during profile fetch gracefully', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await negotiateTerms(
{ cart_id: 'cart-14', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
// Falls back to empty capabilities, negotiation still succeeds
expect(result.negotiation).toBeDefined();
expect(result.error).toBeUndefined();
expect(mockNegotiateCapabilities).toHaveBeenCalledWith(
expect.objectContaining({ capabilities: [] }),
expect.anything(),
expect.anything(),
);
});
// Extra: agent profile with empty services array
it('uses empty capabilities when agent profile has empty services', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ version: '2026-01-11', services: [] }),
{ status: 200 },
),
);
const result = await negotiateTerms(
{ cart_id: 'cart-extra', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
expect(result.negotiation).toBeDefined();
expect(mockNegotiateCapabilities).toHaveBeenCalledWith(
expect.objectContaining({ capabilities: [] }),
expect.anything(),
expect.anything(),
);
});
// Extra: discount code with leading/trailing spaces
it('trims whitespace from discount code before matching', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
const result = await negotiateTerms(
{
cart_id: 'cart-trim',
agent_profile_url: 'https://agent.example.com/profile',
discount_code: ' AGENT10 ',
},
makeConfig(),
);
expect(result.discount_applied).toBe(true);
});
// Extra: AdminAPI returns empty discount list
it('handles AdminAPI returning empty discounts list', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
mockGetDiscountCodes.mockResolvedValue([]);
const result = await negotiateTerms(
{ cart_id: 'cart-empty-discounts', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
// Only default discounts should remain
expect(result.negotiation.available_discounts).toHaveLength(1);
expect(result.negotiation.available_discounts[0]!.code).toBe('AGENT10');
});
// Extra: validates discount from AdminAPI merged discounts
it('can validate discount code that came from AdminAPI', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
mockGetDiscountCodes.mockResolvedValue([
{ code: 'SHOPIFY30', type: 'percentage', value: 30, description: '30% off' },
]);
const result = await negotiateTerms(
{
cart_id: 'cart-admin-discount',
agent_profile_url: 'https://agent.example.com/profile',
discount_code: 'SHOPIFY30',
},
makeConfig(),
);
expect(result.discount_applied).toBe(true);
});
// Edge: cached AdminAPI is reused on second call (covers lines 74-76)
it('reuses cached AdminAPI on subsequent calls', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
mockGetDiscountCodes.mockResolvedValue([]);
const config = makeConfig();
// First call — creates and caches AdminAPI
await negotiateTerms(
{ cart_id: 'cart-cache-1', agent_profile_url: 'https://agent.example.com/profile' },
config,
);
// Second call — should reuse cached AdminAPI (covers the early return at line 75)
await negotiateTerms(
{ cart_id: 'cart-cache-2', agent_profile_url: 'https://agent.example.com/profile' },
config,
);
// getDiscountCodes called both times (same cached instance used)
expect(mockGetDiscountCodes).toHaveBeenCalledTimes(2);
});
// Edge: ShopifyClient constructor throws inside getAdminAPI (covers lines 94-98)
it('handles ShopifyClient constructor error in getAdminAPI gracefully', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
// Make ShopifyClient constructor throw
const { ShopifyClient } = await import('../../src/shopify/client.js');
vi.mocked(ShopifyClient).mockImplementationOnce(() => {
throw new Error('Client init failed');
});
const result = await negotiateTerms(
{ cart_id: 'cart-client-err', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
// Should still succeed, just without AdminAPI discounts
expect(result.negotiation).toBeDefined();
expect(result.error).toBeUndefined();
expect(mockGetDiscountCodes).not.toHaveBeenCalled();
});
// Edge: sessionManager not set (lazy init via getSessionManager, covers lines 51-53)
it('lazily creates a session manager when none is set', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(validAgentProfile), { status: 200 }),
);
// Reset the module-level sessionManager by setting it to undefined
// We do this by importing and calling the internal reset mechanism.
// The setSessionManager was designed for injection; to test lazy init,
// we need to set sessionManager to undefined. We can do this via
// setSessionManager with a workaround — but the actual code path uses
// `!sessionManager`. Since setSessionManager sets the value, we need
// to clear it. We'll use a different approach: re-import to get fresh module state.
// Actually, we can't easily do that with vitest. Instead, we simulate
// the lazy init path by calling negotiateTerms without calling setSessionManager
// in a separate describe block (see below).
// For this test, we rely on the fact that if we set sessionManager = undefined
// and don't call setSessionManager, getSessionManager() will create a new one.
// Since setSessionManager accepts a CheckoutSessionManager, the closest we can
// get is to use the exported function to set it to undefined indirectly.
// But the type won't allow undefined. Let's use a cast:
(setSessionManager as (m: undefined) => void)(undefined as unknown as CheckoutSessionManager);
const result = await negotiateTerms(
{ cart_id: 'cart-lazy', agent_profile_url: 'https://agent.example.com/profile' },
makeConfig(),
);
// A new session should be created (lazy init path)
expect(result.checkout_session).toBeDefined();
expect(result.checkout_session!.id).toBeDefined();
expect(result.checkout_session!.status).toBe('incomplete');
});
});