import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setDocClient } from '../../src/dynamo/client.js';
import { DynamoMandateStore } from '../../src/dynamo/mandate-store.js';
import { DynamoSessionStore } from '../../src/dynamo/session-store.js';
import { DynamoLedgerStore } from '../../src/dynamo/ledger-store.js';
import type {
Mandate,
CartPayload,
CheckoutSession,
FeeRecord,
} from '../../src/types.js';
// ─── Mock DynamoDB Document Client ───
interface SentCommand {
name: string;
input: Record<string, unknown>;
}
function createMockDocClient() {
const sent: SentCommand[] = [];
let nextResponse: 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 });
return { ...nextResponse };
}),
};
return {
client: client as unknown as Parameters<typeof setDocClient>[0],
sent,
setResponse(response: Record<string, unknown>) {
nextResponse = response;
},
reset() {
sent.length = 0;
nextResponse = {};
client.send.mockClear();
},
};
}
// ─── Test Data Helpers ───
function makeCartMandate(): 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',
};
}
function makeCheckoutSession(): CheckoutSession {
return {
id: 'session-1',
status: 'incomplete',
currency: 'USD',
line_items: [
{
id: 'li-1',
product_id: 'prod-1',
variant_id: 'var-1',
title: 'Test Widget',
quantity: 2,
unit_amount: 500,
total_amount: 1000,
type: 'product',
},
],
totals: {
subtotal: 1000,
tax: 80,
shipping: 0,
discount: 0,
fee: 5,
total: 1085,
currency: 'USD',
},
payment_instruments: [],
messages: [
{
type: 'warning',
code: 'missing_payment',
message: 'At least one payment instrument is required.',
},
],
created_at: '2026-02-19T10:00:00.000Z',
updated_at: '2026-02-19T10:00:00.000Z',
};
}
function makeFeeRecord(): FeeRecord {
return {
transaction_id: 'txn-1',
checkout_id: 'checkout-1',
order_id: 'order-1',
gross_amount: 10000,
fee_rate: 0.005,
fee_amount: 50,
currency: 'USD',
wallet_address: '0xABC123',
status: 'collected',
created_at: '2026-02-19T10:00:00.000Z',
};
}
// ─── Setup ───
const mock = createMockDocClient();
beforeEach(() => {
mock.reset();
setDocClient(mock.client);
});
// ─── DynamoMandateStore ───
describe('DynamoMandateStore', () => {
const TABLE = 'test-mandates';
it('store() sends PutCommand with correct mandateId key and mandate data', 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['Item'] as Record<string, unknown>)['mandateId']).toBe('mandate-cart-1');
expect((input['Item'] as Record<string, unknown>)['type']).toBe('cart');
expect((input['Item'] as Record<string, unknown>)['status']).toBe('active');
expect((input['Item'] as Record<string, unknown>)['checkoutId']).toBe('checkout-1');
expect((input['Item'] as Record<string, unknown>)['payload']).toBe(
JSON.stringify(mandate.payload),
);
expect(input['ConditionExpression']).toBe('attribute_not_exists(mandateId)');
});
it('get() sends GetCommand and returns Mandate when Item exists', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makeCartMandate();
mock.setResponse({
Item: {
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,
},
});
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!.payload).toEqual(mandate.payload);
});
it('get() returns null when Item is undefined', 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('getByCheckout() sends QueryCommand with checkoutIndex GSI', async () => {
const store = new DynamoMandateStore(TABLE);
const mandate = makeCartMandate();
mock.setResponse({
Items: [
{
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,
},
],
});
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(1);
expect(results[0]!.id).toBe('mandate-cart-1');
});
it('revoke() 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)');
});
});
// ─── DynamoSessionStore ───
describe('DynamoSessionStore', () => {
const TABLE = 'test-sessions';
it('create() sends PutCommand with sessionId key and TTL', async () => {
const store = new DynamoSessionStore(TABLE);
const session = await store.create('USD');
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('PutCommand');
const input = mock.sent[0]!.input;
expect(input['TableName']).toBe(TABLE);
const item = input['Item'] as Record<string, unknown>;
expect(item['sessionId']).toBeDefined();
expect(item['currency']).toBe('USD');
expect(item['status']).toBe('incomplete');
expect(item['ttl']).toBeDefined();
expect(typeof item['ttl']).toBe('number');
expect(session).toBeDefined();
expect(session.status).toBe('incomplete');
expect(session.currency).toBe('USD');
});
it('get() sends GetCommand and returns CheckoutSession', async () => {
const store = new DynamoSessionStore(TABLE);
const session = makeCheckoutSession();
mock.setResponse({
Item: {
sessionId: session.id,
id: session.id,
status: session.status,
currency: session.currency,
line_items: session.line_items,
totals: session.totals,
payment_instruments: session.payment_instruments,
messages: session.messages,
created_at: session.created_at,
updated_at: session.updated_at,
},
});
const result = await store.get('session-1');
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('GetCommand');
expect((mock.sent[0]!.input['Key'] as Record<string, unknown>)['sessionId']).toBe('session-1');
expect(result).toBeDefined();
expect(result!.id).toBe('session-1');
expect(result!.status).toBe('incomplete');
expect(result!.currency).toBe('USD');
expect(result!.line_items).toHaveLength(1);
});
it('get() returns undefined when not found', async () => {
const store = new DynamoSessionStore(TABLE);
mock.setResponse({});
const result = await store.get('nonexistent');
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('GetCommand');
expect(result).toBeUndefined();
});
it('update() sends GetCommand then PutCommand with recalculated totals/status', async () => {
const store = new DynamoSessionStore(TABLE);
const session = makeCheckoutSession();
// First call (GetCommand) returns the existing session
mock.setResponse({
Item: {
sessionId: session.id,
id: session.id,
status: session.status,
currency: session.currency,
line_items: session.line_items,
totals: session.totals,
payment_instruments: session.payment_instruments,
messages: session.messages,
created_at: session.created_at,
updated_at: session.updated_at,
},
});
const updated = await store.update('session-1', {
buyer: { email: 'buyer@example.com' },
});
// Should have sent at least a GetCommand followed by a PutCommand
expect(mock.sent.length).toBeGreaterThanOrEqual(2);
expect(mock.sent[0]!.name).toBe('GetCommand');
expect(mock.sent[1]!.name).toBe('PutCommand');
const putInput = mock.sent[1]!.input;
expect(putInput['TableName']).toBe(TABLE);
const putItem = putInput['Item'] as Record<string, unknown>;
expect(putItem['sessionId']).toBe('session-1');
// Verify the update returned a session with recalculated data
expect(updated).toBeDefined();
expect(updated.id).toBe('session-1');
});
});
// ─── DynamoLedgerStore ───
describe('DynamoLedgerStore', () => {
const TABLE = 'test-ledger';
it('put() sends PutCommand with transactionId key', async () => {
const store = new DynamoLedgerStore(TABLE);
const record = makeFeeRecord();
await store.put(record);
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('PutCommand');
const input = mock.sent[0]!.input;
expect(input['TableName']).toBe(TABLE);
const item = input['Item'] as Record<string, unknown>;
expect(item['transactionId']).toBe('txn-1');
expect(item['checkout_id']).toBe('checkout-1');
expect(item['order_id']).toBe('order-1');
expect(item['gross_amount']).toBe(10000);
expect(item['fee_rate']).toBe(0.005);
expect(item['fee_amount']).toBe(50);
expect(item['currency']).toBe('USD');
expect(item['wallet_address']).toBe('0xABC123');
expect(item['status']).toBe('collected');
});
it('query() sends ScanCommand and returns FeeRecords', async () => {
const store = new DynamoLedgerStore(TABLE);
const record = makeFeeRecord();
mock.setResponse({
Items: [
{
transactionId: record.transaction_id,
transaction_id: record.transaction_id,
checkout_id: record.checkout_id,
order_id: record.order_id,
gross_amount: record.gross_amount,
fee_rate: record.fee_rate,
fee_amount: record.fee_amount,
currency: record.currency,
wallet_address: record.wallet_address,
status: record.status,
created_at: record.created_at,
},
],
});
const results = await store.query();
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('ScanCommand');
expect(mock.sent[0]!.input['TableName']).toBe(TABLE);
expect(results).toHaveLength(1);
expect(results[0]!.transaction_id).toBe('txn-1');
expect(results[0]!.fee_amount).toBe(50);
expect(results[0]!.currency).toBe('USD');
});
it('query() with date filters sends ScanCommand with FilterExpression', async () => {
const store = new DynamoLedgerStore(TABLE);
mock.setResponse({ Items: [] });
await store.query('2026-02-01T00:00:00.000Z', '2026-02-28T23:59:59.999Z');
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']).toBeDefined();
expect(typeof input['FilterExpression']).toBe('string');
const filterExpr = input['FilterExpression'] as string;
expect(filterExpr).toContain('created_at');
const exprValues = input['ExpressionAttributeValues'] as Record<string, unknown>;
expect(exprValues).toBeDefined();
});
it('totalCollected() sends ScanCommand and sums fee_amount', async () => {
const store = new DynamoLedgerStore(TABLE);
// Mock returns only "collected" records — in real DynamoDB the
// FilterExpression (#status = :collected) filters server-side.
mock.setResponse({
Items: [
{
transactionId: 'txn-1',
fee_amount: 50,
status: 'collected',
currency: 'USD',
created_at: '2026-02-19T10:00:00.000Z',
},
{
transactionId: 'txn-2',
fee_amount: 75,
status: 'collected',
currency: 'USD',
created_at: '2026-02-19T11:00:00.000Z',
},
],
});
const total = await store.totalCollected();
expect(mock.sent).toHaveLength(1);
expect(mock.sent[0]!.name).toBe('ScanCommand');
// Sum of collected records: 50 + 75 = 125
expect(total).toBe(125);
});
});