Skip to content

Commit

Permalink
(open-payments): list outgoing payments (#823)
Browse files Browse the repository at this point in the history
* feat(open-payments): first draft of list outgoing payments

* chore(open-payments): merge main

* feat(open-payments): add proper pagination params

* feat(open-payments): adding listOutgoingPayment tests

* feat(open-payments): update list method call

* feat(open-payments): add tests for query params

* chore(open-payments): remove strict type checking for now

* feat(open-payments): don't provide queryParams if empty

* feat(open-payments): address feedback

* feat(open-payments): add test

* chore(open-payments): fix test type
  • Loading branch information
mkurapov authored Dec 14, 2022
1 parent 1124707 commit 30096d1
Show file tree
Hide file tree
Showing 6 changed files with 320 additions and 6 deletions.
171 changes: 168 additions & 3 deletions packages/open-payments/src/client/outgoing-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createOutgoingPayment,
createOutgoingPaymentRoutes,
getOutgoingPayment,
listOutgoingPayments,
validateOutgoingPayment
} from './outgoing-payment'
import { OpenAPI, HttpMethod, createOpenAPI } from 'openapi'
Expand All @@ -10,7 +11,8 @@ import {
defaultAxiosInstance,
mockOutgoingPayment,
mockOpenApiResponseValidators,
silentLogger
silentLogger,
mockOutgoingPaymentPaginationResult
} from '../test/helpers'
import nock from 'nock'
import { v4 as uuid } from 'uuid'
Expand Down Expand Up @@ -42,6 +44,20 @@ describe('outgoing-payment', (): void => {
})
})

test('creates listOutgoingPaymentOpenApiValidator properly', async (): Promise<void> => {
jest.spyOn(openApi, 'createResponseValidator')

createOutgoingPaymentRoutes({
axiosInstance,
openApi,
logger
})
expect(openApi.createResponseValidator).toHaveBeenCalledWith({
path: '/outgoing-payments',
method: HttpMethod.GET
})
})

test('creates createOutgoingPaymentOpenApiValidator properly', async (): Promise<void> => {
jest.spyOn(openApi, 'createResponseValidator')

Expand Down Expand Up @@ -91,7 +107,7 @@ describe('outgoing-payment', (): void => {
}
})

nock(baseUrl).get('/outgoing-payment').reply(200, outgoingPayment)
nock(baseUrl).get('/outgoing-payments').reply(200, outgoingPayment)

await expect(() =>
getOutgoingPayment(
Expand All @@ -111,7 +127,7 @@ describe('outgoing-payment', (): void => {
test('throws if outgoing payment does not pass open api validation', async (): Promise<void> => {
const outgoingPayment = mockOutgoingPayment()

nock(baseUrl).get('/outgoing-payment').reply(200, outgoingPayment)
nock(baseUrl).get('/outgoing-payments').reply(200, outgoingPayment)

await expect(() =>
getOutgoingPayment(
Expand All @@ -129,6 +145,155 @@ describe('outgoing-payment', (): void => {
})
})

describe('listOutgoingPayment', (): void => {
const paymentPointer = 'http://localhost:1000/.well-known/pay'

describe('forward pagination', (): void => {
test.each`
first | cursor
${undefined} | ${undefined}
${1} | ${undefined}
${5} | ${uuid()}
`(
'returns outgoing payment list',
async ({ first, cursor }): Promise<void> => {
const outgoingPaymentPaginationResult =
mockOutgoingPaymentPaginationResult({
result: Array(first).fill(mockOutgoingPayment())
})

const scope = nock(paymentPointer)
.get('/outgoing-payments')
.query({
...(first ? { first } : {}),
...(cursor ? { cursor } : {})
})
.reply(200, outgoingPaymentPaginationResult)

const result = await listOutgoingPayments(
{
axiosInstance,
logger
},
{
paymentPointer,
accessToken: 'accessToken'
},
openApiValidators.successfulValidator,
{
first,
cursor
}
)
expect(result).toStrictEqual(outgoingPaymentPaginationResult)
scope.done()
}
)
})

describe('backward pagination', (): void => {
test.each`
last | cursor
${undefined} | ${uuid()}
${5} | ${uuid()}
`(
'returns outgoing payment list',
async ({ last, cursor }): Promise<void> => {
const outgoingPaymentPaginationResult =
mockOutgoingPaymentPaginationResult({
result: Array(last).fill(mockOutgoingPayment())
})

const scope = nock(paymentPointer)
.get('/outgoing-payments')
.query({ ...(last ? { last } : {}), cursor })
.reply(200, outgoingPaymentPaginationResult)

const result = await listOutgoingPayments(
{
axiosInstance,
logger
},
{
paymentPointer,
accessToken: 'accessToken'
},
openApiValidators.successfulValidator,
{
last,
cursor
}
)
expect(result).toStrictEqual(outgoingPaymentPaginationResult)
scope.done()
}
)
})

test('throws if an outgoing payment does not pass validation', async (): Promise<void> => {
const invalidOutgoingPayment = mockOutgoingPayment({
sendAmount: {
assetCode: 'CAD',
assetScale: 2,
value: '5'
},
sentAmount: {
assetCode: 'USD',
assetScale: 2,
value: '0'
}
})

const outgoingPaymentPaginationResult =
mockOutgoingPaymentPaginationResult({
result: [invalidOutgoingPayment]
})

const scope = nock(paymentPointer)
.get('/outgoing-payments')
.reply(200, outgoingPaymentPaginationResult)

await expect(() =>
listOutgoingPayments(
{
axiosInstance,
logger
},
{
paymentPointer,
accessToken: 'accessToken'
},
openApiValidators.successfulValidator
)
).rejects.toThrowError(/Could not validate outgoing payment/)
scope.done()
})

test('throws if an outgoing payment does not pass open api validation', async (): Promise<void> => {
const outgoingPaymentPaginationResult =
mockOutgoingPaymentPaginationResult()

const scope = nock(paymentPointer)
.get('/outgoing-payments')
.reply(200, outgoingPaymentPaginationResult)

await expect(() =>
listOutgoingPayments(
{
axiosInstance,
logger
},
{
paymentPointer,
accessToken: 'accessToken'
},
openApiValidators.failedValidator
)
).rejects.toThrowError()
scope.done()
})
})

describe('createOutgoingPayment', (): void => {
const quoteId = `${baseUrl}/quotes/${uuid()}`

Expand Down
71 changes: 70 additions & 1 deletion packages/open-payments/src/client/outgoing-payment.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { HttpMethod, ResponseValidator } from 'openapi'
import { BaseDeps, RouteDeps } from '.'
import { CreateOutgoingPaymentArgs, getRSPath, OutgoingPayment } from '../types'
import {
CreateOutgoingPaymentArgs,
getRSPath,
OutgoingPayment,
OutgoingPaymentPaginationResult,
PaginationArgs
} from '../types'
import { get, post } from './requests'

interface GetArgs {
url: string
accessToken: string
}

interface ListGetArgs {
paymentPointer: string
accessToken: string
}

interface PostArgs<T> {
url: string
body: T
Expand All @@ -16,6 +27,10 @@ interface PostArgs<T> {

export interface OutgoingPaymentRoutes {
get(args: GetArgs): Promise<OutgoingPayment>
list(
args: ListGetArgs,
pagination?: PaginationArgs
): Promise<OutgoingPaymentPaginationResult>
create(args: PostArgs<CreateOutgoingPaymentArgs>): Promise<OutgoingPayment>
}

Expand All @@ -30,6 +45,12 @@ export const createOutgoingPaymentRoutes = (
method: HttpMethod.GET
})

const listOutgoingPaymentOpenApiValidator =
openApi.createResponseValidator<OutgoingPaymentPaginationResult>({
path: getRSPath('/outgoing-payments'),
method: HttpMethod.GET
})

const createOutgoingPaymentOpenApiValidator =
openApi.createResponseValidator<OutgoingPayment>({
path: getRSPath('/outgoing-payments'),
Expand All @@ -43,6 +64,13 @@ export const createOutgoingPaymentRoutes = (
args,
getOutgoingPaymentOpenApiValidator
),
list: (getArgs: ListGetArgs, pagination?: PaginationArgs) =>
listOutgoingPayments(
{ axiosInstance, logger },
getArgs,
listOutgoingPaymentOpenApiValidator,
pagination
),
create: (args: PostArgs<CreateOutgoingPaymentArgs>) =>
createOutgoingPayment(
{ axiosInstance, logger },
Expand Down Expand Up @@ -100,6 +128,47 @@ export const createOutgoingPayment = async (
}
}

export const listOutgoingPayments = async (
deps: BaseDeps,
getArgs: ListGetArgs,
validateOpenApiResponse: ResponseValidator<OutgoingPaymentPaginationResult>,
pagination?: PaginationArgs
) => {
const { axiosInstance, logger } = deps
const { accessToken, paymentPointer } = getArgs
const url = `${paymentPointer}${getRSPath('/outgoing-payments')}`

const outgoingPayments = await get(
{ axiosInstance, logger },
{
url,
accessToken,
...(pagination ? { queryParams: { ...pagination } } : {})
},
validateOpenApiResponse
)

for (const outgoingPayment of outgoingPayments.result) {
try {
validateOutgoingPayment(outgoingPayment)
} catch (error) {
const errorMessage = 'Could not validate outgoing payment'
logger.error(
{
url,
validateError: error?.message,
outgoingPaymentId: outgoingPayment.id
},
errorMessage
)

throw new Error(errorMessage)
}
}

return outgoingPayments
}

export const validateOutgoingPayment = (
payment: OutgoingPayment
): OutgoingPayment => {
Expand Down
40 changes: 40 additions & 0 deletions packages/open-payments/src/client/requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,46 @@ describe('requests', (): void => {
)
})

test.each`
title | queryParams
${'all defined values'} | ${{ first: 5, cursor: 'id' }}
${'some undefined values'} | ${{ first: 5, cursor: undefined }}
${'all undefined values'} | ${{ first: undefined, cursor: undefined }}
`(
'properly sets query params with $title',
async ({ queryParams }): Promise<void> => {
const cleanedQueryParams = Object.fromEntries(
Object.entries(queryParams).filter(([_, v]) => v != null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any

const scope = nock(baseUrl)
.matchHeader('Signature', (sig) => sig === undefined)
.matchHeader('Signature-Input', (sigInput) => sigInput === undefined)
.get('/incoming-payments')
.query(cleanedQueryParams)
.reply(200)

await get(
{ axiosInstance, logger },
{
url: `${baseUrl}/incoming-payments`,
queryParams
},
responseValidators.successfulValidator
)
scope.done()

expect(axiosInstance.get).toHaveBeenCalledWith(
`${baseUrl}/incoming-payments`,
{
headers: {},
params: cleanedQueryParams
}
)
}
)

test('calls validator function properly', async (): Promise<void> => {
const status = 200
const body = {
Expand Down
7 changes: 6 additions & 1 deletion packages/open-payments/src/client/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createSignatureHeaders } from './signatures'

interface GetArgs {
url: string
queryParams?: Record<string, unknown>
accessToken?: string
}

Expand All @@ -16,6 +17,9 @@ interface PostArgs<T = undefined> {
accessToken?: string
}

const removeEmptyValues = (obj: Record<string, unknown>) =>
Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null))

export const get = async <T>(
deps: BaseDeps,
args: GetArgs,
Expand All @@ -37,7 +41,8 @@ export const get = async <T>(
? {
Authorization: `GNAP ${accessToken}`
}
: {}
: {},
params: args.queryParams ? removeEmptyValues(args.queryParams) : undefined
})

try {
Expand Down
Loading

0 comments on commit 30096d1

Please sign in to comment.