/**
* REST Handler — Checkout (isolated unit tests with mocks)
* Targets 95%+ coverage of src/rest/checkout.ts
*/
vi.mock('../../src/dynamo/factory.js', () => ({
getDeps: vi.fn(),
}));
vi.mock('../../src/tools/execute-checkout.js', () => ({
executeCheckout: vi.fn(),
}));
import { handleCheckout } from '../../src/rest/checkout.js';
import type { RESTRequest } from '../../src/rest/types.js';
import { getDeps } from '../../src/dynamo/factory.js';
import { executeCheckout } from '../../src/tools/execute-checkout.js';
// ─── Helpers ───
function makeReq(overrides: Partial<RESTRequest> = {}): RESTRequest {
return {
method: 'GET',
path: '/ucp/v1/checkout',
segments: [],
query: {},
body: null,
headers: {},
...overrides,
};
}
const mockSessionManager = {
create: vi.fn(),
get: vi.fn(),
update: vi.fn(),
};
const mockDeps = {
sessionManager: mockSessionManager,
verifier: { verify: vi.fn() },
mandateStore: { get: vi.fn(), put: vi.fn() },
guardrail: { check: vi.fn() },
feeCollector: { collect: vi.fn() },
storefrontAPI: null,
adminAPI: null,
config: {},
};
// ─── Setup ───
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getDeps).mockReturnValue(mockDeps as any);
});
// ─── Tests ───
describe('handleCheckout', () => {
// 1. POST create session (no body) — returns 201
it('POST with no body creates a new checkout session with 201', async () => {
const session = { id: 'sess-1', status: 'incomplete', currency: 'USD' };
mockSessionManager.create.mockResolvedValue(session);
const res = await handleCheckout(makeReq({ method: 'POST' }));
expect(res.statusCode).toBe(201);
expect(res.body).toEqual(session);
expect(mockSessionManager.create).toHaveBeenCalledWith(undefined);
});
// 2. POST create session with currency — returns 201
it('POST with currency body creates session with specified currency', async () => {
const session = { id: 'sess-2', status: 'incomplete', currency: 'EUR' };
mockSessionManager.create.mockResolvedValue(session);
const res = await handleCheckout(makeReq({
method: 'POST',
body: { currency: 'EUR' },
}));
expect(res.statusCode).toBe(201);
expect(res.body).toEqual(session);
expect(mockSessionManager.create).toHaveBeenCalledWith('EUR');
});
// 3. POST create with invalid body — returns 400
it('POST with invalid currency (wrong length) returns 400', async () => {
const res = await handleCheckout(makeReq({
method: 'POST',
body: { currency: 'TOOLONG' },
}));
expect(res.statusCode).toBe(400);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('bad_request');
expect(body.error.message).toContain('currency');
});
// 4. POST complete checkout — returns 200
it('POST /{id}/complete with valid mandates executes checkout and returns 200', async () => {
const result = { checkout_id: 'sess-1', status: 'completed' };
vi.mocked(executeCheckout).mockResolvedValue(result as any);
const res = await handleCheckout(makeReq({
method: 'POST',
segments: ['sess-1', 'complete'],
body: {
intent_mandate: 'im-signed',
cart_mandate: 'cm-signed',
payment_mandate: 'pm-signed',
},
}));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(result);
expect(executeCheckout).toHaveBeenCalledWith(
{
checkout_id: 'sess-1',
intent_mandate: 'im-signed',
cart_mandate: 'cm-signed',
payment_mandate: 'pm-signed',
},
expect.objectContaining({
sessionManager: mockSessionManager,
verifier: mockDeps.verifier,
mandateStore: mockDeps.mandateStore,
guardrail: mockDeps.guardrail,
feeCollector: mockDeps.feeCollector,
storefrontAPI: null,
}),
);
});
// 5. POST complete with invalid body (missing mandates) — returns 400
it('POST /{id}/complete with missing mandates returns 400', async () => {
const res = await handleCheckout(makeReq({
method: 'POST',
segments: ['sess-1', 'complete'],
body: {},
}));
expect(res.statusCode).toBe(400);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('bad_request');
expect(body.error.message).toContain('mandate');
});
// 6. GET session found — returns 200
it('GET /{id} returns 200 when session is found', async () => {
const session = { id: 'sess-1', status: 'incomplete' };
mockSessionManager.get.mockResolvedValue(session);
const res = await handleCheckout(makeReq({ segments: ['sess-1'] }));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(session);
expect(mockSessionManager.get).toHaveBeenCalledWith('sess-1');
});
// 7. GET session not found — returns 404
it('GET /{id} returns 404 when session is not found', async () => {
mockSessionManager.get.mockResolvedValue(null);
const res = await handleCheckout(makeReq({ segments: ['nonexistent'] }));
expect(res.statusCode).toBe(404);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('not_found');
expect(body.error.message).toContain('nonexistent');
});
// 8. PUT update session — returns 200
it('PUT /{id} with valid body updates session and returns 200', async () => {
const updated = { id: 'sess-1', buyer: { email: 'test@example.com' } };
mockSessionManager.update.mockResolvedValue(updated);
const res = await handleCheckout(makeReq({
method: 'PUT',
segments: ['sess-1'],
body: { buyer: { email: 'test@example.com' } },
}));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(updated);
expect(mockSessionManager.update).toHaveBeenCalledWith('sess-1', { buyer: { email: 'test@example.com' } });
});
// 9. PUT update with invalid body — returns 400
it('PUT /{id} with invalid body returns 400', async () => {
const res = await handleCheckout(makeReq({
method: 'PUT',
segments: ['sess-1'],
body: { buyer: { email: 'not-an-email' } },
}));
expect(res.statusCode).toBe(400);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('bad_request');
expect(body.error.message).toContain('email');
});
// 10. DELETE — returns 405
it('DELETE returns 405 method not allowed', async () => {
const res = await handleCheckout(makeReq({ method: 'DELETE' }));
expect(res.statusCode).toBe(405);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('method_not_allowed');
expect(body.error.message).toContain('GET');
expect(body.error.message).toContain('POST');
expect(body.error.message).toContain('PUT');
});
// 11. Error with 'not found' message → 404
it('catches errors containing "not found" and returns 404', async () => {
mockSessionManager.get.mockRejectedValue(new Error('Session not found in DynamoDB'));
const res = await handleCheckout(makeReq({ segments: ['sess-1'] }));
expect(res.statusCode).toBe(404);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('not_found');
expect(body.error.message).toContain('not found');
});
// 12. Generic error → 500
it('catches generic errors and returns 500', async () => {
mockSessionManager.get.mockRejectedValue(new Error('DynamoDB connection timeout'));
const res = await handleCheckout(makeReq({ segments: ['sess-1'] }));
expect(res.statusCode).toBe(500);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('internal_error');
expect(body.error.message).toContain('DynamoDB connection timeout');
});
// Additional edge cases for thorough coverage
it('POST create with null body (explicitly) succeeds', async () => {
const session = { id: 'sess-3', status: 'incomplete', currency: 'USD' };
mockSessionManager.create.mockResolvedValue(session);
const res = await handleCheckout(makeReq({ method: 'POST', body: null }));
expect(res.statusCode).toBe(201);
expect(mockSessionManager.create).toHaveBeenCalledWith(undefined);
});
it('POST /{id}/complete where executeCheckout throws "not found" returns 404', async () => {
vi.mocked(executeCheckout).mockRejectedValue(new Error('Checkout not found'));
const res = await handleCheckout(makeReq({
method: 'POST',
segments: ['sess-1', 'complete'],
body: {
intent_mandate: 'im-signed',
cart_mandate: 'cm-signed',
payment_mandate: 'pm-signed',
},
}));
expect(res.statusCode).toBe(404);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('not_found');
});
it('POST /{id}/complete where executeCheckout throws generic error returns 500', async () => {
vi.mocked(executeCheckout).mockRejectedValue(new Error('Payment gateway failure'));
const res = await handleCheckout(makeReq({
method: 'POST',
segments: ['sess-1', 'complete'],
body: {
intent_mandate: 'im-signed',
cart_mandate: 'cm-signed',
payment_mandate: 'pm-signed',
},
}));
expect(res.statusCode).toBe(500);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('internal_error');
expect(body.error.message).toContain('Payment gateway failure');
});
it('catches non-Error thrown values and converts to string', async () => {
mockSessionManager.get.mockRejectedValue('raw string error');
const res = await handleCheckout(makeReq({ segments: ['sess-1'] }));
expect(res.statusCode).toBe(500);
const body = res.body as { error: { code: string; message: string } };
expect(body.error.code).toBe('internal_error');
expect(body.error.message).toBe('raw string error');
});
it('PUT on root (no segments) returns 405', async () => {
const res = await handleCheckout(makeReq({ method: 'PUT', segments: [] }));
expect(res.statusCode).toBe(405);
});
it('POST /{id}/complete with empty string mandates returns 400', async () => {
const res = await handleCheckout(makeReq({
method: 'POST',
segments: ['sess-1', 'complete'],
body: {
intent_mandate: '',
cart_mandate: '',
payment_mandate: '',
},
}));
expect(res.statusCode).toBe(400);
});
it('PUT /{id} update with update to multiple fields returns 200', async () => {
const updated = {
id: 'sess-1',
buyer: { email: 'test@example.com' },
currency: 'GBP',
shipping_address: {
line1: '123 Main St',
city: 'London',
postal_code: 'SW1A 1AA',
country: 'GB',
},
};
mockSessionManager.update.mockResolvedValue(updated);
const res = await handleCheckout(makeReq({
method: 'PUT',
segments: ['sess-1'],
body: {
buyer: { email: 'test@example.com' },
currency: 'GBP',
shipping_address: {
line1: '123 Main St',
city: 'London',
postal_code: 'SW1A 1AA',
country: 'GB',
},
},
}));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(updated);
});
});