diff --git a/packages/targets/payment-square/src/index.test.ts b/packages/targets/payment-square/src/index.test.ts index 83be5432..8dd6e5fc 100644 --- a/packages/targets/payment-square/src/index.test.ts +++ b/packages/targets/payment-square/src/index.test.ts @@ -1,8 +1,122 @@ -import { describe, it, expect } from 'vitest'; +import { fakeBuildContext, makeVault } from '@profullstack/sh1pt-core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import target from './index.js'; -describe('payment-square', () => { - it('should have correct metadata', () => { +describe('payment-square target adapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('has correct package metadata', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports const pkg = require('../package.json'); expect(pkg.name).toBe('@profullstack/sh1pt-target-payment-square'); }); + + it('creates payments with validated amount, currency, and sourceId', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ payment: { id: 'pay_123' } }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await target.build(fakeBuildContext({ + secret: makeVault({ + SQUARE_ACCESS_TOKEN: 'square-token', + SQUARE_LOCATION_ID: 'loc_123', + }), + }) as any, { + command: 'create', + args: { amount: 1500, currency: 'usd', sourceId: 'cnon:card-nonce' }, + }); + + expect(result).toEqual({ + artifact: 'square-payment-create', + meta: { raw: JSON.stringify({ payment: { id: 'pay_123' } }) }, + }); + expect(fetchMock).toHaveBeenCalledWith('https://connect.squareup.com/v2/payments', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer square-token', + 'Content-Type': 'application/json', + }), + })); + const body = JSON.parse(String(fetchMock.mock.calls[0]![1].body)); + expect(body).toMatchObject({ + source_id: 'cnon:card-nonce', + amount_money: { amount: 1500, currency: 'USD' }, + location_id: 'loc_123', + }); + }); + + it('rejects invalid create amounts before calling Square', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(target.build(fakeBuildContext({ + secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), + }) as any, { + command: 'create', + args: { amount: 1.5, currency: 'USD', sourceId: 'cnon:card-nonce' }, + })).rejects.toThrow('amount must be a positive integer'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects invalid currency codes before calling Square', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(target.build(fakeBuildContext({ + secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), + }) as any, { + command: 'create', + args: { amount: 1500, currency: 'US', sourceId: 'cnon:card-nonce' }, + })).rejects.toThrow('currency must be a three-letter ISO code'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('requires a sourceId for create commands', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(target.build(fakeBuildContext({ + secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), + }) as any, { + command: 'create', + args: { amount: 1500, currency: 'USD', sourceId: ' ' }, + })).rejects.toThrow('sourceId required'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('requires payment IDs for get, cancel, and refund commands', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const ctx = fakeBuildContext({ + secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), + }) as any; + + await expect(target.build(ctx, { + command: 'get', + args: { paymentId: ' ' }, + })).rejects.toThrow('paymentId required'); + + await expect(target.build(ctx, { + command: 'cancel', + args: {}, + })).rejects.toThrow('paymentId required'); + + await expect(target.build(ctx, { + command: 'refund', + args: {}, + })).rejects.toThrow('paymentId required'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/targets/payment-square/src/index.ts b/packages/targets/payment-square/src/index.ts index fd112135..ef718fa3 100644 --- a/packages/targets/payment-square/src/index.ts +++ b/packages/targets/payment-square/src/index.ts @@ -10,6 +10,24 @@ interface SquareError { errors?: Array<{ code: string; detail: 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): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +} + +function requireCurrency(value: unknown): string { + const currency = typeof value === 'string' ? value.trim().toUpperCase() : 'USD'; + if (!/^[A-Z]{3}$/.test(currency)) throw new Error('currency must be a three-letter ISO code'); + return currency; +} + export default defineTarget({ id: 'payment-square', kind: 'payment', @@ -41,9 +59,9 @@ export default defineTarget({ switch (cmd) { case 'create': { - const amount = config.args?.amount as number || 0; - const currency = config.args?.currency as string || 'USD'; - const sourceId = config.args?.sourceId as string || ''; + const amount = requirePositiveInteger(config.args?.amount, 'amount'); + const currency = requireCurrency(config.args?.currency); + const sourceId = requireText(config.args?.sourceId, 'sourceId'); ctx.log(`square: creating payment of ${amount} ${currency}`); const data = await sq('/payments', { method: 'POST', @@ -54,27 +72,27 @@ export default defineTarget({ location_id: location, }), }); - return { output: JSON.stringify(data) }; + return { artifact: 'square-payment-create', meta: { raw: JSON.stringify(data) } }; } case 'get': { - const id = config.args?.paymentId as string || ''; + const id = requireText(config.args?.paymentId, 'paymentId'); ctx.log(`square: getting payment ${id}`); const data = await sq(`/payments/${id}`); - return { output: JSON.stringify(data) }; + return { artifact: 'square-payment-get', meta: { raw: JSON.stringify(data) } }; } case 'cancel': { - const id = config.args?.paymentId as string || ''; + const id = requireText(config.args?.paymentId, 'paymentId'); ctx.log(`square: canceling payment ${id}`); const data = await sq(`/payments/${id}/cancel`, { method: 'POST' }); - return { output: JSON.stringify(data) }; + return { artifact: 'square-payment-cancel', meta: { raw: JSON.stringify(data) } }; } case 'list': { ctx.log('square: listing payments'); const data = await sq('/payments'); - return { output: JSON.stringify(data) }; + return { artifact: 'square-payment-list', meta: { raw: JSON.stringify(data) } }; } case 'refund': { - const id = config.args?.paymentId as string || ''; + const id = requireText(config.args?.paymentId, 'paymentId'); ctx.log(`square: refunding payment ${id}`); const data = await sq('/refunds', { method: 'POST', @@ -85,7 +103,7 @@ export default defineTarget({ reason: (config.args?.reason as string) || 'requested_by_customer', }), }); - return { output: JSON.stringify(data) }; + return { artifact: 'square-payment-refund', meta: { raw: JSON.stringify(data) } }; } default: throw new Error(`Unknown command: ${cmd}`); @@ -96,7 +114,7 @@ export default defineTarget({ ctx.log('square: verifying setup'); const key = ctx.secret('SQUARE_ACCESS_TOKEN'); if (!key) { - return setupGuide({ + const setup = setupGuide({ title: 'Square Access Token', steps: [ '1. Go to https://developer.squareup.com/apps', @@ -106,7 +124,8 @@ export default defineTarget({ '5. Run: sh1pt secret set SQUARE_ACCESS_TOKEN ', ], }); + return { id: 'setup-required', meta: { setup } }; } - return { status: 'ready' }; + return { id: 'ready', meta: { status: 'ready' } }; }, });