/**
* DynamoMandateStore Tests
* Comprehensive coverage for the DynamoDB-backed mandate store.
* Tests: store, get, getByCheckout, revoke, cleanup, and all error/edge paths.
*/
import { setDocClient } from '../../src/dynamo/client.js';
import { DynamoMandateStore } from '../../src/dynamo/mandate-store.js';
import type { Mandate, CartPayload, PaymentPayload, IntentPayload } from '../../src/types.js';
// ─── Mock DynamoDB Document Client ───
interface SentCommand {
name: string;
input: Record<string, unknown>;
}
function createMockDocClient() {
const sent: SentCommand[] = [];
let responseQueue: Record<string, unknown>[] = [];
let defaultResponse: Record<string, unknown> = {};
const client = {
send: vi.fn(async (command: { constructor: { name: string }; input: Record<string, unknown> }) => {
sent.push({ name: command.constructor.name, input: command.input });
if (responseQueue.length > 0) {
return { ...responseQueue.shift()! };
}
return { ...defaultResponse };
}),
};
return {
client: client as unknown as Parameters<typeof setDocClient>[0],
sent,
setResponse(response: Record<string, unknown>) {
defaultResponse = response;
responseQueue = [];
},
queueResponses(...responses: Record<string, unknown>[]) {
responseQueue = [...responses];
},
reset() {
sent.length = 0;
defaultResponse = {};
responseQueue = [];
client.send.mockClear();
},
};
}
// ─── Test Data ───
const TABLE = 'test-mandates';
function makeCartMandate(overrides: Partial<Mandate> = {}): Mandate {
return {
id: 'mandate-cart-1',
type: 'cart',
status: 'active',
issuer: 'merchant-1',
subject: 'buyer@example.com',
payload: {
checkout_id: 'checkout-1',
line_items: [],
totals: {
subtotal: 1000,
tax: 80,
shipping: 0,
discount: 0,
fee: 5,
total: 1085,
currency: 'USD',
},
merchant_signature: 'sig-placeholder',
} as CartPayload,
signature: 'jws-placeholder',
issued_at: '2026-02-19T10:00:00.000Z',
expires_at: '2026-02-19T10:30:00.000Z',
...overrides,
};
}
function makePaymentMandate(overrides: Partial<Mandate> = {}): Mandate {
return {
id: 'mandate-pay-1',
type: 'payment',
status: 'active',
issuer: 'merchant-1',
subject: 'buyer@example.com',
payload: {
checkout_id: 'checkout-1',
amount: 1085,
currency: 'USD',
payment_handler_id: 'handler-1',
cart_mandate_id: 'mandate-cart-1',
} as PaymentPayload,
signature: 'jws-payment',
issued_at: '2026-02-19T10:00:00.000Z',
expires_at: '2026-02-19T10:30:00.000Z',
...overrides,
};
}
function makeIntentMandate(overrides: Partial<Mandate> = {}): Mandate {
return {
id: 'mandate-intent-1',
type: 'intent',
status: 'active',
issuer: 'buyer-agent',
subject: 'buyer@example.com',
payload: {
max_amount: 5000,
currency: 'USD',
categories: ['electronics'],
} as IntentPayload,
signature: 'jws-intent',
issued_at: '2026-02-19T10:00:00.000Z',
expires_at: '2026-02-19T11:00:00.000Z',
...overrides,
};
}
function mandateToItem(mandate: Mandate): Record<string, unknown> {
return {
mandateId: mandate.id,
type: mandate.type,
status: mandate.status,
issuer: mandate.issuer,
subject: mandate.subject,
payload: JSON.stringify(mandate.payload),
signature: mandate.signature,
issued_at: mandate.issued_at,
expires_at: mandate.expires_at,
};
}
// ─── Setup ───
const mock = createMockDocClient();
beforeEach(() => {
mock.reset();
setDocClient(mock.client);
});
// ─── store() ───
describe('DynamoMandateStore.store()', () => {
it('puts a cart mandate with correct fields and condition expression', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makeCartMandate();
await store.store(mandate);
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('PutCommand');
const input = mock.sent[0]!.input;
expect(input['TableName']).toBe(TABLE);
expect(input['ConditionExpression']).toBe('attribute_not_exists(mandateId)');
const item = input['Item'] as Record<string, unknown>;
expect(item['mandateId']).toBe('mandate-cart-1');
expect(item['type']).toBe('cart');
expect(item['status']).toBe('active');
expect(item['issuer']).toBe('merchant-1');
expect(item['subject']).toBe('buyer@example.com');
expect(item['signature']).toBe('jws-placeholder');
expect(item['issued_at']).toBe('2026-02-19T10:00:00.000Z');
expect(item['expires_at']).toBe('2026-02-19T10:30:00.000Z');
// Payload is JSON-stringified
expect(item['payload']).toBe(JSON.stringify(mandate.payload));
// checkoutId extracted from cart payload
expect(item['checkoutId']).toBe('checkout-1');
});
it('extracts checkoutId from payment mandate payload', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makePaymentMandate();
await store.store(mandate);
const item = (mock.sent[0]!.input['Item'] as Record<string, unknown>);
expect(item['checkoutId']).toBe('checkout-1');
});
it('sets checkoutId to "none" for intent mandates (no checkout_id)', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makeIntentMandate();
await store.store(mandate);
const item = (mock.sent[0]!.input['Item'] as Record<string, unknown>);
expect(item['checkoutId']).toBe('none');
});
it('stringifies the payload as JSON', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makeCartMandate();
await store.store(mandate);
const item = (mock.sent[0]!.input['Item'] as Record<string, unknown>);
const parsed = JSON.parse(item['payload'] as string);
expect(parsed.checkout_id).toBe('checkout-1');
expect(parsed.merchant_signature).toBe('sig-placeholder');
});
});
// ─── get() ───
describe('DynamoMandateStore.get()', () => {
it('returns mandate when found', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makeCartMandate();
mock.setResponse({ Item: mandateToItem(mandate) });
const result = await store.get('mandate-cart-1');
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('GetCommand');
expect((mock.sent[0]!.input['Key'] as Record<string, unknown>)['mandateId']).toBe(
'mandate-cart-1',
);
expect(result).not.toBeNull();
expect(result!.id).toBe('mandate-cart-1');
expect(result!.type).toBe('cart');
expect(result!.status).toBe('active');
expect(result!.issuer).toBe('merchant-1');
expect(result!.subject).toBe('buyer@example.com');
expect(result!.signature).toBe('jws-placeholder');
// Payload should be parsed from JSON
expect(result!.payload).toEqual(mandate.payload);
expect((result!.payload as CartPayload).checkout_id).toBe('checkout-1');
});
it('returns null when not found', async () => {
const store = new DynamoMandateStore(TABLE);
mock.setResponse({});
const result = await store.get('nonexistent');
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('GetCommand');
expect(result).toBeNull();
});
it('correctly reconstructs payment mandate from DynamoDB item', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makePaymentMandate();
mock.setResponse({ Item: mandateToItem(mandate) });
const result = await store.get('mandate-pay-1');
expect(result).not.toBeNull();
expect(result!.type).toBe('payment');
expect((result!.payload as PaymentPayload).amount).toBe(1085);
expect((result!.payload as PaymentPayload).payment_handler_id).toBe('handler-1');
expect((result!.payload as PaymentPayload).cart_mandate_id).toBe('mandate-cart-1');
});
it('correctly reconstructs intent mandate from DynamoDB item', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makeIntentMandate();
mock.setResponse({ Item: mandateToItem(mandate) });
const result = await store.get('mandate-intent-1');
expect(result).not.toBeNull();
expect(result!.type).toBe('intent');
expect((result!.payload as IntentPayload).max_amount).toBe(5000);
expect((result!.payload as IntentPayload).categories).toEqual(['electronics']);
});
});
// ─── getByCheckout() ───
describe('DynamoMandateStore.getByCheckout()', () => {
it('returns array of mandates for a checkout ID', async () => {
const store = new DynamoMandateStore(TABLE);
const cartMandate = makeCartMandate();
const payMandate = makePaymentMandate();
mock.setResponse({
Items: [mandateToItem(cartMandate), mandateToItem(payMandate)],
});
const results = await store.getByCheckout('checkout-1');
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('QueryCommand');
const input = mock.sent[0]!.input;
expect(input['TableName']).toBe(TABLE);
expect(input['IndexName']).toBe('checkoutIndex');
expect(input['KeyConditionExpression']).toBe('checkoutId = :cid');
expect(input['ExpressionAttributeValues']).toEqual({ ':cid': 'checkout-1' });
expect(results).toHaveLength(2);
expect(results[0]!.id).toBe('mandate-cart-1');
expect(results[1]!.id).toBe('mandate-pay-1');
});
it('returns empty array when no mandates found', async () => {
const store = new DynamoMandateStore(TABLE);
mock.setResponse({ Items: [] });
const results = await store.getByCheckout('checkout-nonexistent');
expect(results).toEqual([]);
});
it('returns empty array when Items is undefined', async () => {
const store = new DynamoMandateStore(TABLE);
mock.setResponse({});
const results = await store.getByCheckout('checkout-missing');
expect(results).toEqual([]);
});
});
// ─── revoke() ───
describe('DynamoMandateStore.revoke()', () => {
it('sends UpdateCommand setting status to "revoked"', async () => {
const store = new DynamoMandateStore(TABLE);
await store.revoke('mandate-cart-1');
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('UpdateCommand');
const input = mock.sent[0]!.input;
expect(input['TableName']).toBe(TABLE);
expect((input['Key'] as Record<string, unknown>)['mandateId']).toBe('mandate-cart-1');
expect(input['UpdateExpression']).toBe('SET #status = :revoked');
expect(input['ExpressionAttributeNames']).toEqual({ '#status': 'status' });
expect(input['ExpressionAttributeValues']).toEqual({ ':revoked': 'revoked' });
expect(input['ConditionExpression']).toBe('attribute_exists(mandateId)');
});
it('throws when DynamoDB condition check fails (mandate not found)', async () => {
const store = new DynamoMandateStore(TABLE);
const conditionError = new Error('The conditional request failed');
conditionError.name = 'ConditionalCheckFailedException';
// Use mockImplementationOnce so it only affects the next call
(mock.client as unknown as { send: ReturnType<typeof vi.fn> }).send.mockRejectedValueOnce(conditionError);
await expect(store.revoke('nonexistent')).rejects.toThrow(
'The conditional request failed',
);
});
});
// ─── cleanup() ───
describe('DynamoMandateStore.cleanup()', () => {
it('returns 0 when no expired mandates found', async () => {
const store = new DynamoMandateStore(TABLE);
mock.setResponse({ Items: [] });
const count = await store.cleanup();
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('ScanCommand');
const input = mock.sent[0]!.input;
expect(input['TableName']).toBe(TABLE);
expect(input['FilterExpression']).toBe('expires_at < :now');
expect(count).toBe(0);
});
it('returns 0 when Items is undefined in scan result', async () => {
const store = new DynamoMandateStore(TABLE);
mock.setResponse({}); // No Items field
const count = await store.cleanup();
expect(count).toBe(0);
});
it('deletes expired mandates and returns count', async () => {
const store = new DynamoMandateStore(TABLE);
mock.queueResponses(
// ScanCommand returns 3 expired items
{
Items: [
{ mandateId: 'expired-1' },
{ mandateId: 'expired-2' },
{ mandateId: 'expired-3' },
],
},
// BatchWriteCommand response
{},
);
const count = await store.cleanup();
expect(mock.sent).toHaveLength(2);
expect(mock.sent[0]!.name).toBe('ScanCommand');
expect(mock.sent[1]!.name).toBe('BatchWriteCommand');
const batchInput = mock.sent[1]!.input;
const requestItems = batchInput['RequestItems'] as Record<string, unknown[]>;
expect(requestItems[TABLE]).toHaveLength(3);
expect(requestItems[TABLE][0]).toEqual({
DeleteRequest: { Key: { mandateId: 'expired-1' } },
});
expect(requestItems[TABLE][1]).toEqual({
DeleteRequest: { Key: { mandateId: 'expired-2' } },
});
expect(requestItems[TABLE][2]).toEqual({
DeleteRequest: { Key: { mandateId: 'expired-3' } },
});
expect(count).toBe(3);
});
it('handles more than 25 items (batches of 25)', async () => {
const store = new DynamoMandateStore(TABLE);
// Generate 30 expired items
const items = Array.from({ length: 30 }, (_, i) => ({
mandateId: `expired-${i}`,
}));
mock.queueResponses(
// ScanCommand returns 30 items
{ Items: items },
// First BatchWriteCommand (25 items)
{},
// Second BatchWriteCommand (5 items)
{},
);
const count = await store.cleanup();
expect(mock.sent).toHaveLength(3); // 1 scan + 2 batch writes
expect(mock.sent[0]!.name).toBe('ScanCommand');
expect(mock.sent[1]!.name).toBe('BatchWriteCommand');
expect(mock.sent[2]!.name).toBe('BatchWriteCommand');
// First batch: 25 items
const batch1 = (mock.sent[1]!.input['RequestItems'] as Record<string, unknown[]>)[TABLE];
expect(batch1).toHaveLength(25);
// Second batch: 5 items
const batch2 = (mock.sent[2]!.input['RequestItems'] as Record<string, unknown[]>)[TABLE];
expect(batch2).toHaveLength(5);
expect(count).toBe(30);
});
it('uses current timestamp for expiration comparison', async () => {
const store = new DynamoMandateStore(TABLE);
mock.setResponse({ Items: [] });
await store.cleanup();
const input = mock.sent[0]!.input;
const exprValues = input['ExpressionAttributeValues'] as Record<string, string>;
const now = exprValues[':now'];
// The :now value should be a valid ISO date string close to current time
expect(typeof now).toBe('string');
const parsedDate = new Date(now);
expect(parsedDate.getTime()).toBeGreaterThan(0);
const diffMs = Math.abs(Date.now() - parsedDate.getTime());
expect(diffMs).toBeLessThan(5000); // Within 5 seconds
});
it('handles exactly 25 items in a single batch', async () => {
const store = new DynamoMandateStore(TABLE);
const items = Array.from({ length: 25 }, (_, i) => ({
mandateId: `expired-${i}`,
}));
mock.queueResponses({ Items: items }, {});
const count = await store.cleanup();
expect(mock.sent).toHaveLength(2); // 1 scan + 1 batch write
expect(count).toBe(25);
const batch = (mock.sent[1]!.input['RequestItems'] as Record<string, unknown[]>)[TABLE];
expect(batch).toHaveLength(25);
});
});