diff --git a/packages/targets/payment-stripe/src/index.test.ts b/packages/targets/payment-stripe/src/index.test.ts index c03a6f84..6b4b378a 100644 --- a/packages/targets/payment-stripe/src/index.test.ts +++ b/packages/targets/payment-stripe/src/index.test.ts @@ -1,7 +1,98 @@ -import { contractTestTarget } from '@profullstack/sh1pt-core/testing'; +import { contractTestTarget, fakeShipContext } from '@profullstack/sh1pt-core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execMock } = vi.hoisted(() => ({ + execMock: vi.fn(), +})); + +vi.mock('@profullstack/sh1pt-core', async () => ({ + ...await vi.importActual('@profullstack/sh1pt-core'), + exec: execMock, +})); + import target from './index.js'; contractTestTarget(target, { sampleConfig: { command: 'create', args: { amount: 2000, currency: 'usd' }, description: 'test payment' }, - requiredSecrets: ['STRIPE_API_KEY'], +}); + +beforeEach(() => { + vi.clearAllMocks(); + execMock.mockResolvedValue({ exitCode: 0, stdout: '{"ok":true}', stderr: '' }); +}); + +describe('payment-stripe target adapter', () => { + it('normalizes currency before invoking the Stripe CLI', async () => { + const ctx = fakeShipContext({ dryRun: false }); + + await target.ship(ctx as any, { + command: 'create', + args: { amount: 2500, currency: 'USD' }, + description: 'Test payment', + }); + + expect(execMock).toHaveBeenCalledWith('stripe', [ + 'payment_intents', + 'create', + '--amount', + '2500', + '--currency', + 'usd', + '--description', + 'Test payment', + ], { + log: ctx.log, + throwOnNonZero: true, + }); + }); + + it('rejects invalid create amounts before invoking the Stripe CLI', async () => { + await expect(target.ship(fakeShipContext({ dryRun: false }) as any, { + command: 'create', + args: { amount: 12.5, currency: 'usd' }, + })).rejects.toThrow('amount must be a positive integer'); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('rejects invalid currency codes before invoking the Stripe CLI', async () => { + await expect(target.ship(fakeShipContext({ dryRun: false }) as any, { + command: 'create', + args: { amount: 1000, currency: 'US' }, + })).rejects.toThrow('currency must be a three-letter ISO code'); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('requires payment intent IDs for get and refund commands', async () => { + await expect(target.ship(fakeShipContext({ dryRun: false }) as any, { + command: 'get', + args: { paymentIntentId: ' ' }, + })).rejects.toThrow('paymentIntentId required'); + + await expect(target.ship(fakeShipContext({ dryRun: false }) as any, { + command: 'refund', + args: {}, + })).rejects.toThrow('paymentIntentId required'); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('requires a positive integer list limit', async () => { + await expect(target.ship(fakeShipContext({ dryRun: false }) as any, { + command: 'list', + args: { limit: -1 }, + })).rejects.toThrow('limit must be a positive integer'); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('requires a valid customer email', async () => { + await expect(target.ship(fakeShipContext({ dryRun: false }) as any, { + command: 'customer', + args: { email: 'not-an-email' }, + })).rejects.toThrow('email must be a valid email address'); + + expect(execMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/targets/payment-stripe/src/index.ts b/packages/targets/payment-stripe/src/index.ts index dcc0dcc3..02a54604 100644 --- a/packages/targets/payment-stripe/src/index.ts +++ b/packages/targets/payment-stripe/src/index.ts @@ -6,6 +6,31 @@ interface Config { description?: string; } +function requireText(value: unknown, name: string): string { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} required`); + return value.trim(); +} + +function requirePositiveInteger(value: unknown, name: string, defaultValue: number): number { + const numberValue = value ?? defaultValue; + if (typeof numberValue !== 'number' || !Number.isInteger(numberValue) || numberValue <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return numberValue; +} + +function requireCurrency(value: unknown): string { + const currency = typeof value === 'string' ? value.trim().toLowerCase() : 'usd'; + if (!/^[a-z]{3}$/.test(currency)) throw new Error('currency must be a three-letter ISO code'); + return currency; +} + +function requireEmail(value: unknown): string { + const email = requireText(value, 'email'); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('email must be a valid email address'); + return email; +} + export default defineTarget({ id: 'payment-stripe', kind: 'payment', @@ -50,8 +75,8 @@ export default defineTarget({ switch (cmd) { case 'create': { const args = ['payment_intents', 'create']; - const amount = config.args?.amount ?? 100; - const currency = (config.args?.currency as string) ?? 'usd'; + const amount = requirePositiveInteger(config.args?.amount, 'amount', 100); + const currency = requireCurrency(config.args?.currency); args.push('--amount', String(amount)); args.push('--currency', currency); if (config.description) args.push('--description', config.description); @@ -61,28 +86,25 @@ export default defineTarget({ } case 'get': { - const pi = config.args?.paymentIntentId as string; - if (!pi) throw new Error('paymentIntentId required'); + const pi = requireText(config.args?.paymentIntentId, 'paymentIntentId'); const { stdout } = await exec('stripe', ['payment_intents', 'retrieve', pi], { log: ctx.log }); return { id: pi, meta: { raw: stdout.trim() } }; } case 'list': { - const limit = config.args?.limit ?? 10; + const limit = requirePositiveInteger(config.args?.limit, 'limit', 10); const { stdout } = await exec('stripe', ['payment_intents', 'list', `--limit=${limit}`], { log: ctx.log }); return { id: `list-${Date.now()}`, meta: { raw: stdout.trim() } }; } case 'customer': { - const email = config.args?.email as string; - if (!email) throw new Error('email required'); + const email = requireEmail(config.args?.email); const { stdout } = await exec('stripe', ['customers', 'create', `--email=${email}`], { log: ctx.log }); return { id: `cus_${Date.now()}`, meta: { raw: stdout.trim() } }; } case 'refund': { - const pi = config.args?.paymentIntentId as string; - if (!pi) throw new Error('paymentIntentId required'); + const pi = requireText(config.args?.paymentIntentId, 'paymentIntentId'); const { stdout } = await exec('stripe', ['refunds', 'create', `--payment-intent=${pi}`], { log: ctx.log }); return { id: `refund_${Date.now()}`, meta: { raw: stdout.trim() } }; }