diff --git a/packages/backend/migrations/20220406190843_create_quotes_table.js b/packages/backend/migrations/20220819162331_create_quotes_table.js similarity index 93% rename from packages/backend/migrations/20220406190843_create_quotes_table.js rename to packages/backend/migrations/20220819162331_create_quotes_table.js index 66b2cd0dbb..b868fa5fe9 100644 --- a/packages/backend/migrations/20220406190843_create_quotes_table.js +++ b/packages/backend/migrations/20220819162331_create_quotes_table.js @@ -26,6 +26,9 @@ exports.up = function (knex) { table.uuid('assetId').notNullable() table.foreign('assetId').references('assets.id') + table.string('grantId').nullable() + table.foreign('grantId').references('grantReferences.id') + table.timestamp('createdAt').defaultTo(knex.fn.now()) table.timestamp('updatedAt').defaultTo(knex.fn.now()) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index bcc890ced6..99fed135c0 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -77,6 +77,7 @@ export type AppRequest = Omit< export interface PaymentPointerContext extends AppContext { paymentPointer: PaymentPointer grant?: Grant + clientId?: string } // Payment pointer subresources diff --git a/packages/backend/src/connector/core/factories/rafiki-services.ts b/packages/backend/src/connector/core/factories/rafiki-services.ts index 12da56d01d..30ac033a91 100644 --- a/packages/backend/src/connector/core/factories/rafiki-services.ts +++ b/packages/backend/src/connector/core/factories/rafiki-services.ts @@ -32,7 +32,8 @@ export const RafikiServicesFactory = Factory.define( 'incomingPayments', ['accounting'], (accounting: MockAccountingService) => ({ - get: async (id: string) => await accounting._getIncomingPayment(id), + get: async ({ id }: { id: string }) => + await accounting._getIncomingPayment(id), handlePayment: async (_id: string) => { return undefined } diff --git a/packages/backend/src/connector/core/middleware/account.ts b/packages/backend/src/connector/core/middleware/account.ts index bfeca0720e..92b31758db 100644 --- a/packages/backend/src/connector/core/middleware/account.ts +++ b/packages/backend/src/connector/core/middleware/account.ts @@ -46,9 +46,9 @@ export function createAccountMiddleware(serverAddress: string): ILPMiddleware { OutgoingAccount | undefined > => { if (ctx.state.streamDestination) { - const incomingPayment = await incomingPayments.get( - ctx.state.streamDestination - ) + const incomingPayment = await incomingPayments.get({ + id: ctx.state.streamDestination + }) if (incomingPayment) { if ( incomingPayment.state === IncomingPaymentState.Completed || diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.ts b/packages/backend/src/graphql/resolvers/incoming_payment.ts index fe56e66770..9fdafb9df2 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.ts @@ -26,15 +26,17 @@ export const getPaymentPointerIncomingPayments: PaymentPointerResolvers - incomingPaymentService.getPaymentPointerPage( - parent.id as string, + incomingPaymentService.getPaymentPointerPage({ + paymentPointerId: parent.id as string, pagination - ), + }), incomingPayments ) diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index ad1316be20..539f2501a3 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -22,7 +22,9 @@ export const getOutgoingPayment: QueryResolvers['outgoingPayment' const outgoingPaymentService = await ctx.container.use( 'outgoingPaymentService' ) - const payment = await outgoingPaymentService.get(args.id) + const payment = await outgoingPaymentService.get({ + id: args.id + }) if (!payment) throw new Error('payment does not exist') return paymentToGraphql(payment) } @@ -69,15 +71,17 @@ export const getPaymentPointerOutgoingPayments: PaymentPointerResolvers - outgoingPaymentService.getPaymentPointerPage( - parent.id as string, + outgoingPaymentService.getPaymentPointerPage({ + paymentPointerId: parent.id as string, pagination - ), + }), outgoingPayments ) return { diff --git a/packages/backend/src/graphql/resolvers/quote.ts b/packages/backend/src/graphql/resolvers/quote.ts index a1031094bf..58a6890825 100644 --- a/packages/backend/src/graphql/resolvers/quote.ts +++ b/packages/backend/src/graphql/resolvers/quote.ts @@ -22,7 +22,9 @@ export const getQuote: QueryResolvers['quote'] = async ( ctx ): Promise => { const quoteService = await ctx.container.use('quoteService') - const quote = await quoteService.get(args.id) + const quote = await quoteService.get({ + id: args.id + }) if (!quote) throw new Error('quote does not exist') return quoteToGraphql(quote) } @@ -56,10 +58,16 @@ export const getPaymentPointerQuotes: PaymentPointerResolvers['qu async (parent, args, ctx): Promise => { if (!parent.id) throw new Error('missing payment pointer id') const quoteService = await ctx.container.use('quoteService') - const quotes = await quoteService.getPaymentPointerPage(parent.id, args) + const quotes = await quoteService.getPaymentPointerPage({ + paymentPointerId: parent.id, + pagination: args + }) const pageInfo = await getPageInfo( (pagination: Pagination) => - quoteService.getPaymentPointerPage(parent.id as string, pagination), + quoteService.getPaymentPointerPage({ + paymentPointerId: parent.id as string, + pagination + }), quotes ) return { diff --git a/packages/backend/src/open_payments/auth/grant.test.ts b/packages/backend/src/open_payments/auth/grant.test.ts index 3cf1bed3ba..b09bd534d4 100644 --- a/packages/backend/src/open_payments/auth/grant.test.ts +++ b/packages/backend/src/open_payments/auth/grant.test.ts @@ -3,7 +3,7 @@ import { Interval } from 'luxon' import { v4 as uuid } from 'uuid' describe('Grant', (): void => { - describe('includesAccess', (): void => { + describe('findAccess', (): void => { let grant: Grant const type = AccessType.IncomingPayment const action = AccessAction.Create @@ -36,12 +36,12 @@ describe('Grant', (): void => { test('Returns true for included access', async (): Promise => { expect( - grant.includesAccess({ + grant.findAccess({ type, action, identifier }) - ).toBe(true) + ).toEqual(grant.access[1]) }) test.each` superAction | subAction | description @@ -63,12 +63,12 @@ describe('Grant', (): void => { ] }) expect( - grant.includesAccess({ + grant.findAccess({ type, action: subAction, identifier }) - ).toBe(true) + ).toEqual(grant.access[0]) } ) @@ -80,34 +80,34 @@ describe('Grant', (): void => { 'Returns false for missing $description', async ({ type, action, identifier }): Promise => { expect( - grant.includesAccess({ + grant.findAccess({ type, action, identifier }) - ).toBe(false) + ).toBeUndefined() } ) if (identifier) { test('Returns false for missing identifier', async (): Promise => { expect( - grant.includesAccess({ + grant.findAccess({ type, action, identifier: 'https://wallet.example/bob' }) - ).toBe(false) + ).toBeUndefined() }) } else { test('Returns true for unrestricted identifier', async (): Promise => { expect( - grant.includesAccess({ + grant.findAccess({ type, action, identifier: 'https://wallet.example/bob' }) - ).toBe(true) + ).toEqual(grant.access[1]) }) } }) diff --git a/packages/backend/src/open_payments/auth/grant.ts b/packages/backend/src/open_payments/auth/grant.ts index 7ab695a156..494567b776 100644 --- a/packages/backend/src/open_payments/auth/grant.ts +++ b/packages/backend/src/open_payments/auth/grant.ts @@ -79,7 +79,7 @@ export class Grant { public readonly access: GrantAccess[] public readonly clientId: string - public includesAccess({ + public findAccess({ type, action, identifier @@ -87,8 +87,8 @@ export class Grant { type: AccessType action: AccessAction identifier: string - }): boolean { - return !!this.access?.find( + }): GrantAccess | undefined { + return this.access?.find( (access) => access.type === type && (!access.identifier || access.identifier === identifier) && diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index 2b169fc21b..546908e1a0 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -10,12 +10,12 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../' import { AppServices, PaymentPointerContext } from '../../app' import { HttpMethod, ValidateFunction } from 'openapi' -import { setup } from '../../shared/routes.test' import { createTestApp, TestContainer } from '../../tests/app' import { createPaymentPointer } from '../../tests/paymentPointer' import { truncateTables } from '../../tests/tableManager' import { GrantReference } from '../grantReference/model' import { GrantReferenceService } from '../grantReference/service' +import { setup } from '../payment_pointer/model.test' type AppMiddleware = ( ctx: PaymentPointerContext, diff --git a/packages/backend/src/open_payments/auth/middleware.ts b/packages/backend/src/open_payments/auth/middleware.ts index 5b51a806ef..8def2a9327 100644 --- a/packages/backend/src/open_payments/auth/middleware.ts +++ b/packages/backend/src/open_payments/auth/middleware.ts @@ -37,13 +37,12 @@ export function createAuthMiddleware({ if (!grant || !grant.active) { ctx.throw(401, 'Invalid Token') } - if ( - !grant.includesAccess({ - type, - action, - identifier: ctx.paymentPointer.url - }) - ) { + const access = grant.findAccess({ + type, + action, + identifier: ctx.paymentPointer.url + }) + if (!access) { ctx.throw(403, 'Insufficient Grant') } await GrantReference.transaction(async (trx: Transaction) => { @@ -66,6 +65,13 @@ export function createAuthMiddleware({ } }) ctx.grant = grant + + // Unless the relevant grant action is ReadAll/ListAll add the + // clientId to ctx for Read/List filtering + if (access.actions.includes(action)) { + ctx.clientId = grant.clientId + } + await next() } catch (err) { if (err.status === 401) { diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index b385c51e1c..7f4b141134 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -3,13 +3,14 @@ import { v4 as uuid } from 'uuid' import { Amount, AmountJSON } from '../../amount' import { ConnectionJSON } from '../../connection/service' -import { PaymentPointer } from '../../payment_pointer/model' +import { + PaymentPointer, + PaymentPointerSubresource +} from '../../payment_pointer/model' import { Asset } from '../../../asset/model' import { LiquidityAccount, OnCreditOptions } from '../../../accounting/service' import { ConnectorAccount } from '../../../connector/core/rafiki' -import { BaseModel } from '../../../shared/baseModel' import { WebhookEvent } from '../../../webhook/model' -import { GrantReference } from '../../grantReference/model' export enum IncomingPaymentEventType { IncomingPaymentExpired = 'incoming_payment.expired', @@ -52,46 +53,32 @@ export class IncomingPaymentEvent extends WebhookEvent { } export class IncomingPayment - extends BaseModel + extends PaymentPointerSubresource implements ConnectorAccount, LiquidityAccount { public static get tableName(): string { return 'incomingPayments' } + public static readonly urlPath = '/incoming-payments' static get virtualAttributes(): string[] { return ['completed', 'incomingAmount', 'receivedAmount', 'url'] } - static relationMappings = { - asset: { - relation: Model.HasOneRelation, - modelClass: Asset, - join: { - from: 'incomingPayments.assetId', - to: 'assets.id' - } - }, - paymentPointer: { - relation: Model.BelongsToOneRelation, - modelClass: PaymentPointer, - join: { - from: 'incomingPayments.paymentPointerId', - to: 'paymentPointers.id' - } - }, - grantRef: { - relation: Model.HasOneRelation, - modelClass: GrantReference, - join: { - from: 'incomingPayments.grantId', - to: 'grantReferences.id' + static get relationMappings() { + return { + ...super.relationMappings, + asset: { + relation: Model.HasOneRelation, + modelClass: Asset, + join: { + from: 'incomingPayments.assetId', + to: 'assets.id' + } } } } - // Open payments paymentPointer id this incoming payment is for - public paymentPointerId!: string public paymentPointer!: PaymentPointer public description?: string public expiresAt!: Date @@ -100,9 +87,6 @@ export class IncomingPayment // The "| null" is necessary so that `$beforeUpdate` can modify a patch to remove the connectionId. If `$beforeUpdate` set `error = undefined`, the patch would ignore the modification. public connectionId?: string | null - public grantId?: string - public grantRef?: GrantReference - public processAt!: Date | null public readonly assetId!: string @@ -143,7 +127,7 @@ export class IncomingPayment } public get url(): string { - return `${this.paymentPointer.url}/incoming-payments/${this.id}` + return `${this.paymentPointer.url}${IncomingPayment.urlPath}/${this.id}` } public async onCredit({ diff --git a/packages/backend/src/open_payments/payment/incoming/routes.test.ts b/packages/backend/src/open_payments/payment/incoming/routes.test.ts index b58f35e2d7..900c2b24fd 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.test.ts @@ -1,17 +1,16 @@ import jestOpenAPI from 'jest-openapi' -import base64url from 'base64url' import { Knex } from 'knex' import { v4 as uuid } from 'uuid' -import { Amount } from '../../amount' +import { Amount, serializeAmount } from '../../amount' import { PaymentPointer } from '../../payment_pointer/model' +import { getRouteTests, setup } from '../../payment_pointer/model.test' import { createTestApp, TestContainer } from '../../../tests/app' import { Config, IAppConfig } from '../../../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../..' import { AppServices, - ReadContext, CreateContext, CompleteContext, ListContext @@ -19,9 +18,9 @@ import { import { truncateTables } from '../../../tests/tableManager' import { IncomingPayment, IncomingPaymentJSON } from './model' import { IncomingPaymentRoutes, CreateBody, MAX_EXPIRY } from './routes' +import { createGrant } from '../../../tests/grant' import { createIncomingPayment } from '../../../tests/incomingPayment' import { createPaymentPointer } from '../../../tests/paymentPointer' -import { listTests, setup } from '../../../shared/routes.test' import { AccessAction, AccessType, Grant } from '../../auth/grant' import { GrantReference as GrantModel } from '../../grantReference/model' @@ -80,110 +79,65 @@ describe('Incoming Payment Routes', (): void => { await appContainer.shutdown() }) - describe('get', (): void => { - let incomingPayment: IncomingPayment - let grant: Grant - beforeEach(async (): Promise => { - incomingPayment = await createIncomingPayment(deps, { - paymentPointerId: paymentPointer.id, - grantId: grantRef.id, - description, - expiresAt, - incomingAmount, - externalRef - }) - grant = new Grant({ - active: true, - grant: grantRef.id, - clientId: grantRef.clientId, - access: [ - { - type: AccessType.IncomingPayment, - actions: [AccessAction.Read] - } - ] - }) + describe('get/list', (): void => { + getRouteTests({ + createGrant: async (options) => createGrant(deps, options), + getPaymentPointer: async () => paymentPointer, + createModel: async ({ grant }) => + createIncomingPayment(deps, { + paymentPointerId: paymentPointer.id, + grantId: grant?.grant, + description, + expiresAt, + incomingAmount, + externalRef + }), + get: (ctx) => incomingPaymentRoutes.get(ctx), + getBody: (incomingPayment, list) => ({ + id: incomingPayment.url, + paymentPointer: paymentPointer.url, + completed: false, + incomingAmount: serializeAmount(incomingPayment.incomingAmount), + description: incomingPayment.description, + expiresAt: incomingPayment.expiresAt.toISOString(), + createdAt: incomingPayment.createdAt.toISOString(), + updatedAt: incomingPayment.updatedAt.toISOString(), + receivedAmount: serializeAmount(incomingPayment.receivedAmount), + externalRef: '#123', + ilpStreamConnection: list + ? `${config.openPaymentsUrl}/connections/${incomingPayment.connectionId}` + : { + id: `${config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`, + ilpAddress: expect.stringMatching( + /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ + ), + sharedSecret: expect.stringMatching(/^[a-zA-Z0-9-_]{43}$/), + assetCode: incomingPayment.incomingAmount.assetCode, + assetScale: incomingPayment.incomingAmount.assetScale + } + }), + list: (ctx) => incomingPaymentRoutes.list(ctx), + urlPath: IncomingPayment.urlPath }) - describe.each` - withGrant | description - ${false} | ${'without grant'} - ${true} | ${'with grant'} - `('$description', ({ withGrant }): void => { - test('returns 404 on unknown incoming payment', async (): Promise => { - const ctx = setup({ - reqOpts: { - headers: { Accept: 'application/json' } - }, - params: { - id: uuid() - }, - paymentPointer, - grant: withGrant ? grant : undefined - }) - await expect(incomingPaymentRoutes.get(ctx)).rejects.toMatchObject({ - status: 404, - message: 'Not Found' - }) + test('returns 500 for unexpected error', async (): Promise => { + const incomingPaymentService = await deps.use('incomingPaymentService') + jest + .spyOn(incomingPaymentService, 'getPaymentPointerPage') + .mockRejectedValueOnce(new Error('unexpected')) + const ctx = setup({ + reqOpts: { + headers: { Accept: 'application/json' } + }, + paymentPointer }) - - test('returns 200 with an open payments incoming payment', async (): Promise => { - const ctx = setup({ - reqOpts: { - headers: { Accept: 'application/json' }, - method: 'GET', - url: `/incoming-payments/${incomingPayment.id}` - }, - params: { - id: incomingPayment.id - }, - paymentPointer, - grant: withGrant ? grant : undefined - }) - await expect(incomingPaymentRoutes.get(ctx)).resolves.toBeUndefined() - expect(ctx.response).toSatisfyApiSpec() - - const sharedSecret = ( - (ctx.response.body as Record)[ - 'ilpStreamConnection' - ] as Record - )['sharedSecret'] - - expect(ctx.body).toEqual({ - id: incomingPayment.url, - paymentPointer: paymentPointer.url, - completed: false, - incomingAmount: { - value: '123', - assetCode: asset.code, - assetScale: asset.scale - }, - description: incomingPayment.description, - expiresAt: expiresAt.toISOString(), - createdAt: incomingPayment.createdAt.toISOString(), - updatedAt: incomingPayment.updatedAt.toISOString(), - receivedAmount: { - value: '0', - assetCode: asset.code, - assetScale: asset.scale - }, - externalRef: '#123', - ilpStreamConnection: { - id: `${config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`, - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret, - assetCode: asset.code, - assetScale: asset.scale - } - }) - const sharedSecretBuffer = Buffer.from(sharedSecret as string, 'base64') - expect(sharedSecretBuffer).toHaveLength(32) - expect(sharedSecret).toEqual(base64url(sharedSecretBuffer)) + await expect(incomingPaymentRoutes.list(ctx)).rejects.toMatchObject({ + status: 500, + message: `Error trying to list incoming payments` }) }) }) + describe.each` withGrant | description ${false} | ${'without grant'} @@ -340,74 +294,4 @@ describe('Incoming Payment Routes', (): void => { }) }) }) - - describe('list', (): void => { - let grant: Grant - beforeEach(async (): Promise => { - grant = new Grant({ - active: true, - grant: grantRef.id, - clientId: grantRef.clientId, - access: [ - { - type: AccessType.IncomingPayment, - actions: [AccessAction.List] - } - ] - }) - }) - describe.each` - withGrant | description - ${false} | ${'without grant'} - ${true} | ${'with grant'} - `('$description', ({ withGrant }): void => { - listTests({ - getPaymentPointer: () => paymentPointer, - getGrant: () => (withGrant ? grant : undefined), - getUrl: () => `/incoming-payments`, - createItem: async (index: number) => { - const payment = await createIncomingPayment(deps, { - paymentPointerId: paymentPointer.id, - grantId: withGrant ? grantRef.id : undefined, - description: `p${index}`, - expiresAt - }) - return { - id: payment.url, - paymentPointer: paymentPointer.url, - receivedAmount: { - value: '0', - assetCode: asset.code, - assetScale: asset.scale - }, - description: payment.description, - completed: false, - expiresAt: expiresAt.toISOString(), - createdAt: payment.createdAt.toISOString(), - updatedAt: payment.updatedAt.toISOString(), - ilpStreamConnection: `${config.openPaymentsUrl}/connections/${payment.connectionId}` - } - }, - list: (ctx: ListContext) => incomingPaymentRoutes.list(ctx) - }) - - test('returns 500 for unexpected error', async (): Promise => { - const incomingPaymentService = await deps.use('incomingPaymentService') - jest - .spyOn(incomingPaymentService, 'getPaymentPointerPage') - .mockRejectedValueOnce(new Error('unexpected')) - const ctx = setup({ - reqOpts: { - headers: { Accept: 'application/json' } - }, - paymentPointer, - grant: withGrant ? grant : undefined - }) - await expect(incomingPaymentRoutes.list(ctx)).rejects.toMatchObject({ - status: 500, - message: `Error trying to list incoming payments` - }) - }) - }) - }) }) diff --git a/packages/backend/src/open_payments/payment/incoming/routes.ts b/packages/backend/src/open_payments/payment/incoming/routes.ts index 64c53daaf9..cfbf71b680 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.ts @@ -15,13 +15,8 @@ import { isIncomingPaymentError } from './errors' import { AmountJSON, parseAmount } from '../../amount' -import { - getPageInfo, - parsePaginationQueryParameters -} from '../../../shared/pagination' -import { Pagination } from '../../../shared/baseModel' +import { listSubresource } from '../../payment_pointer/routes' import { ConnectionJSON, ConnectionService } from '../../connection/service' -import { AccessAction } from '../../auth/grant' // Don't allow creating an incoming payment too far out. Incoming payments with no payments before they expire are cleaned up, since incoming payments creation is unauthenticated. // TODO what is a good default value for this? @@ -62,20 +57,12 @@ async function getIncomingPayment( ctx: ReadContext ): Promise { let incomingPayment: IncomingPayment | undefined - let clientId = undefined - const incomingAccess = ctx.grant?.access.filter( - (access) => access.type === 'incoming-payment' - ) - if (incomingAccess && incomingAccess.length === 1) { - clientId = incomingAccess[0].actions.includes(AccessAction.ReadAll) - ? undefined - : ctx.grant.clientId - } try { - incomingPayment = await deps.incomingPaymentService.get( - ctx.params.id, - clientId - ) + incomingPayment = await deps.incomingPaymentService.get({ + id: ctx.params.id, + clientId: ctx.clientId, + paymentPointerId: ctx.paymentPointer.id + }) } catch (err) { ctx.throw(500, 'Error trying to get incoming payment') } @@ -155,42 +142,17 @@ async function listIncomingPayments( deps: ServiceDependencies, ctx: ListContext ): Promise { - const pagination = parsePaginationQueryParameters(ctx.request.query) - let clientId = undefined - const incomingAccess = ctx.grant?.access.filter( - (access) => access.type === 'incoming-payment' - ) - if (incomingAccess && incomingAccess.length === 1) { - clientId = incomingAccess[0].actions.includes(AccessAction.ListAll) - ? undefined - : ctx.grant.clientId - } try { - const page = await deps.incomingPaymentService.getPaymentPointerPage( - ctx.paymentPointer.id, - pagination, - clientId - ) - const pageInfo = await getPageInfo( - (pagination: Pagination) => - deps.incomingPaymentService.getPaymentPointerPage( - ctx.paymentPointer.id, - pagination, - clientId - ), - page - ) - const result = { - pagination: pageInfo, - result: page.map((item: IncomingPayment) => { - return incomingPaymentToBody( + await listSubresource({ + ctx, + getPaymentPointerPage: deps.incomingPaymentService.getPaymentPointerPage, + toBody: (payment) => + incomingPaymentToBody( deps, - item, - deps.connectionService.getUrl(item) + payment, + deps.connectionService.getUrl(payment) ) - }) - } - ctx.body = result + }) } catch (_) { ctx.throw(500, 'Error trying to list incoming payments') } diff --git a/packages/backend/src/open_payments/payment/incoming/service.test.ts b/packages/backend/src/open_payments/payment/incoming/service.test.ts index bb07f6a1a8..0fdbafd6ba 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -15,15 +15,14 @@ import { Config } from '../../../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../..' import { AppServices } from '../../../app' -import { Pagination } from '../../../shared/baseModel' -import { getPageTests } from '../../../shared/baseModel.test' import { randomAsset } from '../../../tests/asset' +import { createGrant } from '../../../tests/grant' import { createIncomingPayment } from '../../../tests/incomingPayment' import { createPaymentPointer } from '../../../tests/paymentPointer' import { truncateTables } from '../../../tests/tableManager' import { IncomingPaymentError, isIncomingPaymentError } from './errors' -import { GrantReference } from '../../grantReference/model' import { GrantReferenceService } from '../../grantReference/service' +import { getTests } from '../../payment_pointer/model.test' describe('Incoming Payment Service', (): void => { let deps: IocContract @@ -192,59 +191,26 @@ describe('Incoming Payment Service', (): void => { }) ).resolves.toBe(IncomingPaymentError.InvalidExpiry) }) - - test('Cannot fetch a bogus incoming payment', async (): Promise => { - await expect(incomingPaymentService.get(uuid())).resolves.toBeUndefined() - }) }) - describe('Get incoming payment', (): void => { - let incomingPayment: IncomingPayment - let grantRef: GrantReference - beforeEach(async (): Promise => { - grantRef = await grantReferenceService.create({ - id: uuid(), - clientId: uuid() - }) - incomingPayment = (await incomingPaymentService.create({ - paymentPointerId, - grantId: grantRef.id, - incomingAmount: { - value: BigInt(123), - assetCode: asset.code, - assetScale: asset.scale - }, - expiresAt: new Date(Date.now() + 30_000), - description: 'Test incoming payment', - externalRef: '#123' - })) as IncomingPayment - assert.ok(!isIncomingPaymentError(incomingPayment)) - }) - test('get an incoming payment', async (): Promise => { - const retrievedIncomingPayment = await incomingPaymentService.get( - incomingPayment.id - ) - assert.ok(retrievedIncomingPayment) - expect(retrievedIncomingPayment).toEqual(incomingPayment) - }) - test('get an incoming payment for client id', async (): Promise => { - const retrievedIncomingPayment = await incomingPaymentService.get( - incomingPayment.id, - grantRef.clientId - ) - assert.ok(retrievedIncomingPayment) - expect(retrievedIncomingPayment).toEqual({ - ...incomingPayment, - grantRef: grantRef - }) - }) - test('cannot get incoming payment if client id does not match', async (): Promise => { - const clientId = uuid() - const retrievedIncomingPayment = await incomingPaymentService.get( - incomingPayment.id, - clientId - ) - expect(retrievedIncomingPayment).toBeUndefined() + describe('get/getPaymentPointerPage', (): void => { + getTests({ + createGrant: async (options) => createGrant(deps, options), + createModel: ({ grant }) => + createIncomingPayment(deps, { + paymentPointerId, + grantId: grant?.grant, + incomingAmount: { + value: BigInt(123), + assetCode: asset.code, + assetScale: asset.scale + }, + expiresAt: new Date(Date.now() + 30_000), + description: 'Test incoming payment', + externalRef: '#123' + }), + get: (options) => incomingPaymentService.get(options), + list: (options) => incomingPaymentService.getPaymentPointerPage(options) }) }) @@ -278,7 +244,9 @@ describe('Incoming Payment Service', (): void => { processAt: new Date(incomingPayment.expiresAt.getTime()) }) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Processing, processAt: new Date(incomingPayment.expiresAt.getTime()) @@ -301,7 +269,9 @@ describe('Incoming Payment Service', (): void => { connectionId: null }) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, processAt: new Date(now.getTime() + 30_000), @@ -329,7 +299,9 @@ describe('Incoming Payment Service', (): void => { incomingPaymentService.processNext() ).resolves.toBeUndefined() await expect( - incomingPaymentService.get(incomingPaymentId) + incomingPaymentService.get({ + id: incomingPaymentId + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Pending }) @@ -363,7 +335,9 @@ describe('Incoming Payment Service', (): void => { incomingPayment.id ) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Expired, processAt: new Date(now.getTime() + 30_000), @@ -389,7 +363,9 @@ describe('Incoming Payment Service', (): void => { incomingPayment.id ) expect( - await incomingPaymentService.get(incomingPayment.id) + await incomingPaymentService.get({ + id: incomingPayment.id + }) ).toBeUndefined() }) }) @@ -434,9 +410,9 @@ describe('Incoming Payment Service', (): void => { totalReceived: incomingPayment.incomingAmount!.value }) } - incomingPayment = (await incomingPaymentService.get( - incomingPayment.id - )) as IncomingPayment + incomingPayment = (await incomingPaymentService.get({ + id: incomingPayment.id + })) as IncomingPayment expect(incomingPayment).toMatchObject({ state: eventType === IncomingPaymentEventType.IncomingPaymentExpired @@ -473,7 +449,9 @@ describe('Incoming Payment Service', (): void => { }) ).resolves.toHaveLength(1) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ processAt: null }) @@ -482,61 +460,6 @@ describe('Incoming Payment Service', (): void => { ) }) - describe.each` - client | description - ${false} | ${'without client'} - ${true} | ${'with client'} - `('Incoming payment pagination - $description', ({ client }): void => { - let grantRef: GrantReference - beforeEach(async (): Promise => { - grantRef = await grantReferenceService.create({ - id: uuid(), - clientId: uuid() - }) - if (client) { - const secondGrant = await grantReferenceService.create({ - id: uuid(), - clientId: uuid() - }) - for (let i = 0; i < 10; i++) { - await createIncomingPayment(deps, { - paymentPointerId, - grantId: secondGrant.id, - incomingAmount: { - value: BigInt(789), - assetCode: asset.code, - assetScale: asset.scale - }, - expiresAt: new Date(Date.now() + 30_000), - description: 'IncomingPayment', - externalRef: '#456' - }) - } - } - }) - getPageTests({ - createModel: () => - createIncomingPayment(deps, { - paymentPointerId, - grantId: grantRef.id, - incomingAmount: { - value: BigInt(123), - assetCode: asset.code, - assetScale: asset.scale - }, - expiresAt: new Date(Date.now() + 30_000), - description: 'IncomingPayment', - externalRef: '#123' - }), - getPage: (pagination: Pagination) => - incomingPaymentService.getPaymentPointerPage( - paymentPointerId, - pagination, - client ? grantRef.clientId : undefined - ) - }) - }) - describe('complete', (): void => { let incomingPayment: IncomingPayment @@ -565,7 +488,9 @@ describe('Incoming Payment Service', (): void => { connectionId: null }) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, processAt: new Date(now.getTime() + 30_000), @@ -584,7 +509,9 @@ describe('Incoming Payment Service', (): void => { totalReceived: BigInt(100) }) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Processing }) @@ -597,7 +524,9 @@ describe('Incoming Payment Service', (): void => { connectionId: null }) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, processAt: new Date(incomingPayment.expiresAt.getTime()), @@ -620,7 +549,9 @@ describe('Incoming Payment Service', (): void => { incomingPayment.id ) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Expired, connectionId: null @@ -629,7 +560,9 @@ describe('Incoming Payment Service', (): void => { incomingPaymentService.complete(incomingPayment.id) ).resolves.toBe(IncomingPaymentError.WrongState) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Expired, connectionId: null @@ -641,7 +574,9 @@ describe('Incoming Payment Service', (): void => { totalReceived: BigInt(123) }) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, connectionId: null @@ -650,7 +585,9 @@ describe('Incoming Payment Service', (): void => { incomingPaymentService.complete(incomingPayment.id) ).resolves.toBe(IncomingPaymentError.WrongState) await expect( - incomingPaymentService.get(incomingPayment.id) + incomingPaymentService.get({ + id: incomingPayment.id + }) ).resolves.toMatchObject({ state: IncomingPaymentState.Completed, connectionId: null diff --git a/packages/backend/src/open_payments/payment/incoming/service.ts b/packages/backend/src/open_payments/payment/incoming/service.ts index 6253c7e578..6f9baa8ef4 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.ts @@ -5,12 +5,16 @@ import { IncomingPaymentState } from './model' import { AccountingService } from '../../../accounting/service' -import { Pagination } from '../../../shared/baseModel' import { BaseService } from '../../../shared/baseService' import assert from 'assert' import { Knex } from 'knex' import { TransactionOrKnex } from 'objection' -import { PaymentPointerService } from '../../payment_pointer/service' +import { GetOptions, ListOptions } from '../../payment_pointer/model' +import { + PaymentPointerService, + PaymentPointerSubresourceService +} from '../../payment_pointer/service' + import { Amount } from '../../amount' import { IncomingPaymentError } from './errors' import { end, parse } from 'iso8601-duration' @@ -32,18 +36,13 @@ export interface CreateIncomingPaymentOptions { externalRef?: string } -export interface IncomingPaymentService { - get(id: string, clientId?: string): Promise +export interface IncomingPaymentService + extends PaymentPointerSubresourceService { create( options: CreateIncomingPaymentOptions, trx?: Knex.Transaction ): Promise complete(id: string): Promise - getPaymentPointerPage( - paymentPointerId: string, - pagination?: Pagination, - clientId?: string - ): Promise processNext(): Promise getByConnection(connectionId: string): Promise } @@ -65,34 +64,34 @@ export async function createIncomingPaymentService( logger: log } return { - get: (id, clientId) => getIncomingPayment(deps, 'id', id, clientId), + get: (options) => getIncomingPayment(deps, options), create: (options, trx) => createIncomingPayment(deps, options, trx), complete: (id) => completeIncomingPayment(deps, id), - getPaymentPointerPage: (paymentPointerId, pagination, clientId) => - getPaymentPointerPage(deps, paymentPointerId, pagination, clientId), + getPaymentPointerPage: (options) => getPaymentPointerPage(deps, options), processNext: () => processNextIncomingPayment(deps), getByConnection: (connectionId) => - getIncomingPayment(deps, 'connectionId', connectionId) + getIncomingPaymentByConnection(deps, connectionId) } } async function getIncomingPayment( deps: ServiceDependencies, - key: string, - value: string, - clientId?: string + options: GetOptions ): Promise { - let incomingPayment: IncomingPayment - if (!clientId) { - incomingPayment = await IncomingPayment.query(deps.knex) - .findOne(key, value) - .withGraphFetched('[asset, paymentPointer]') - } else { - incomingPayment = await IncomingPayment.query(deps.knex) - .findOne(`incomingPayments.${key}`, value) - .withGraphJoined('[asset, paymentPointer, grantRef]') - .where('grantRef.clientId', clientId) - } + const incomingPayment = await IncomingPayment.query(deps.knex) + .get(options) + .withGraphFetched('[asset, paymentPointer]') + if (incomingPayment) return await addReceivedAmount(deps, incomingPayment) + else return +} + +async function getIncomingPaymentByConnection( + deps: ServiceDependencies, + connectionId: string +): Promise { + const incomingPayment = await IncomingPayment.query(deps.knex) + .findOne({ connectionId }) + .withGraphFetched('[asset, paymentPointer]') if (incomingPayment) return await addReceivedAmount(deps, incomingPayment) else return } @@ -254,26 +253,11 @@ async function handleDeactivated( async function getPaymentPointerPage( deps: ServiceDependencies, - paymentPointerId: string, - pagination?: Pagination, - clientId?: string + options: ListOptions ): Promise { - let page: IncomingPayment[] - if (!clientId) { - page = await IncomingPayment.query(deps.knex) - .getPage(pagination) - .where({ - paymentPointerId - }) - .withGraphFetched('[asset, paymentPointer]') - } else { - page = await IncomingPayment.query(deps.knex) - .getPage(pagination) - .where('incomingPayments.paymentPointerId', paymentPointerId) - .andWhere('grantRef.clientId', clientId) - .withGraphJoined('[asset, paymentPointer, grantRef]') - } - + const page = await IncomingPayment.query(deps.knex) + .list(options) + .withGraphFetched('[asset, paymentPointer]') const amounts = await deps.accountingService.getAccountsTotalReceived( page.map((payment: IncomingPayment) => payment.id) ) diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index 0ddeae490f..5844f80ab8 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -3,18 +3,17 @@ import { Model, ModelOptions, Pojo, QueryContext } from 'objection' import { LiquidityAccount } from '../../../accounting/service' import { Asset } from '../../../asset/model' import { ConnectorAccount } from '../../../connector/core/rafiki' -import { PaymentPointer } from '../../payment_pointer/model' +import { PaymentPointerSubresource } from '../../payment_pointer/model' import { Quote } from '../../quote/model' import { Amount, AmountJSON } from '../../amount' -import { BaseModel } from '../../../shared/baseModel' import { WebhookEvent } from '../../../webhook/model' -import { GrantReference } from '../../grantReference/model' export class OutgoingPayment - extends BaseModel + extends PaymentPointerSubresource implements ConnectorAccount, LiquidityAccount { public static readonly tableName = 'outgoingPayments' + public static readonly urlPath = '/outgoing-payments' static get virtualAttributes(): string[] { return ['sendAmount', 'receiveAmount', 'quote', 'sentAmount', 'receiver'] @@ -25,9 +24,6 @@ export class OutgoingPayment public error?: string | null public stateAttempts!: number - public grantId?: string - public grantRef?: GrantReference - public get receiver(): string { return this.quote.receiver } @@ -55,10 +51,6 @@ export class OutgoingPayment public description?: string public externalRef?: string - // Open payments payment pointer id of the sender - public paymentPointerId!: string - public paymentPointer?: PaymentPointer - public quote!: Quote public get assetId(): string { @@ -72,29 +64,16 @@ export class OutgoingPayment // Outgoing peer public peerId?: string - static relationMappings = { - paymentPointer: { - relation: Model.BelongsToOneRelation, - modelClass: PaymentPointer, - join: { - from: 'outgoingPayments.paymentPointerId', - to: 'paymentPointers.id' - } - }, - quote: { - relation: Model.HasOneRelation, - modelClass: Quote, - join: { - from: 'outgoingPayments.id', - to: 'quotes.id' - } - }, - grantRef: { - relation: Model.HasOneRelation, - modelClass: GrantReference, - join: { - from: 'outgoingPayments.grantId', - to: 'grantReferences.id' + static get relationMappings() { + return { + ...super.relationMappings, + quote: { + relation: Model.HasOneRelation, + modelClass: Quote, + join: { + from: 'outgoingPayments.id', + to: 'quotes.id' + } } } } diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts index 4744a94725..075bf7eb15 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts @@ -8,23 +8,19 @@ import { createTestApp, TestContainer } from '../../../tests/app' import { Config, IAppConfig } from '../../../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../..' -import { - AppServices, - ReadContext, - CreateContext, - ListContext -} from '../../../app' +import { AppServices, CreateContext, ListContext } from '../../../app' import { truncateTables } from '../../../tests/tableManager' import { randomAsset } from '../../../tests/asset' import { CreateOutgoingPaymentOptions } from './service' import { OutgoingPayment, OutgoingPaymentState } from './model' import { OutgoingPaymentRoutes, CreateBody } from './routes' +import { serializeAmount } from '../../amount' import { PaymentPointer } from '../../payment_pointer/model' +import { getRouteTests } from '../../payment_pointer/model.test' +import { createGrant } from '../../../tests/grant' import { createOutgoingPayment } from '../../../tests/outgoingPayment' import { createPaymentPointer } from '../../../tests/paymentPointer' -import { listTests, setup } from '../../../shared/routes.test' import { AccessAction, AccessType, Grant } from '../../auth/grant' -import { GrantReference as GrantModel } from '../../grantReference/model' describe('Outgoing Payment Routes', (): void => { let deps: IocContract @@ -68,10 +64,7 @@ describe('Outgoing Payment Routes', (): void => { beforeEach(async (): Promise => { paymentPointer = await createPaymentPointer(deps, { asset }) - grant = new Grant({ - active: true, - clientId: uuid(), - grant: uuid(), + grant = await createGrant(deps, { access: [ { type: AccessType.OutgoingPayment, @@ -79,10 +72,6 @@ describe('Outgoing Payment Routes', (): void => { } ] }) - await GrantModel.query().insert({ - id: grant.grant, - clientId: grant.clientId - }) }) afterEach(async (): Promise => { @@ -93,86 +82,68 @@ describe('Outgoing Payment Routes', (): void => { await appContainer.shutdown() }) - describe('get', (): void => { - describe.each` - withGrant | description - ${false} | ${'without grant'} - ${true} | ${'with grant'} - `('$description', ({ withGrant }): void => { - test('returns 404 for nonexistent outgoing payment', async (): Promise => { - const ctx = setup({ - reqOpts: { - headers: { Accept: 'application/json' } - }, - params: { - id: uuid() - }, - paymentPointer, - grant: withGrant ? grant : undefined + describe.each` + failed | description + ${false} | ${''} + ${true} | ${' failed'} + `('get/list$description outgoing payment', ({ failed }): void => { + getRouteTests({ + createGrant: async ({ clientId }) => + createGrant(deps, { + clientId, + access: [ + { + type: AccessType.OutgoingPayment, + actions: [AccessAction.Create, AccessAction.Read] + } + ] + }), + getPaymentPointer: async () => paymentPointer, + createModel: async ({ grant }) => { + const outgoingPayment = await createPayment({ + paymentPointerId: paymentPointer.id, + grant, + description: 'rent', + externalRef: '202201' }) - await expect(outgoingPaymentRoutes.get(ctx)).rejects.toHaveProperty( - 'status', - 404 - ) - }) - - test.each` - failed | description - ${false} | ${''} - ${true} | ${'failed '} - `( - 'returns the $description outgoing payment on success', - async ({ failed }): Promise => { - const outgoingPayment = await createPayment({ - paymentPointerId: paymentPointer.id, - grant, - description: 'rent', - externalRef: '202201' - }) - if (failed) { - await outgoingPayment - .$query(knex) - .patch({ state: OutgoingPaymentState.Failed }) - } - const ctx = setup({ - reqOpts: { - headers: { Accept: 'application/json' }, - method: 'GET', - url: `/outgoing-payments/${outgoingPayment.id}` - }, - params: { - id: outgoingPayment.id - }, - paymentPointer, - grant: withGrant ? grant : undefined - }) - await expect(outgoingPaymentRoutes.get(ctx)).resolves.toBeUndefined() - expect(ctx.response).toSatisfyApiSpec() - expect(ctx.body).toEqual({ - id: `${paymentPointer.url}/outgoing-payments/${outgoingPayment.id}`, - paymentPointer: paymentPointer.url, - receiver: outgoingPayment.receiver, - sendAmount: { - ...outgoingPayment.sendAmount, - value: outgoingPayment.sendAmount.value.toString() - }, - sentAmount: { - value: '0', - assetCode: asset.code, - assetScale: asset.scale - }, - receiveAmount: { - ...outgoingPayment.receiveAmount, - value: outgoingPayment.receiveAmount.value.toString() - }, - description: outgoingPayment.description, - externalRef: outgoingPayment.externalRef, - failed, - createdAt: outgoingPayment.createdAt.toISOString(), - updatedAt: outgoingPayment.updatedAt.toISOString() - }) + if (failed) { + await outgoingPayment + .$query(knex) + .patch({ state: OutgoingPaymentState.Failed }) } - ) + return outgoingPayment + }, + get: (ctx) => outgoingPaymentRoutes.get(ctx), + getBody: (outgoingPayment) => ({ + id: `${paymentPointer.url}/outgoing-payments/${outgoingPayment.id}`, + paymentPointer: paymentPointer.url, + receiver: outgoingPayment.receiver, + sendAmount: serializeAmount(outgoingPayment.sendAmount), + sentAmount: serializeAmount(outgoingPayment.sentAmount), + receiveAmount: serializeAmount(outgoingPayment.receiveAmount), + description: outgoingPayment.description, + externalRef: outgoingPayment.externalRef, + failed, + createdAt: outgoingPayment.createdAt.toISOString(), + updatedAt: outgoingPayment.updatedAt.toISOString() + }), + list: (ctx) => outgoingPaymentRoutes.list(ctx), + urlPath: OutgoingPayment.urlPath + }) + + test('returns 500 for unexpected error', async (): Promise => { + const outgoingPaymentService = await deps.use('outgoingPaymentService') + jest + .spyOn(outgoingPaymentService, 'getPaymentPointerPage') + .mockRejectedValueOnce(new Error('unexpected')) + const ctx = createContext({ + headers: { Accept: 'application/json' } + }) + ctx.paymentPointer = paymentPointer + await expect(outgoingPaymentRoutes.list(ctx)).rejects.toMatchObject({ + status: 500, + message: `Error trying to list outgoing payments` + }) }) }) @@ -265,63 +236,4 @@ describe('Outgoing Payment Routes', (): void => { } ) }) - - describe('list', (): void => { - describe.each` - withGrant | description - ${false} | ${'without grant'} - ${true} | ${'with grant'} - `('$description', ({ withGrant }): void => { - listTests({ - getPaymentPointer: () => paymentPointer, - getGrant: () => (withGrant ? grant : undefined), - getUrl: () => `/outgoing-payments`, - createItem: async (index: number) => { - const payment = await createPayment({ - paymentPointerId: paymentPointer.id, - grant, - description: `p${index}` - }) - return { - id: `${paymentPointer.url}/outgoing-payments/${payment.id}`, - paymentPointer: paymentPointer.url, - receiver: payment.receiver, - sendAmount: { - ...payment.sendAmount, - value: payment.sendAmount.value.toString() - }, - receiveAmount: { - ...payment.receiveAmount, - value: payment.receiveAmount.value.toString() - }, - sentAmount: { - value: '0', - assetCode: asset.code, - assetScale: asset.scale - }, - failed: false, - description: payment.description, - createdAt: payment.createdAt.toISOString(), - updatedAt: payment.updatedAt.toISOString() - } - }, - list: (ctx: ListContext) => outgoingPaymentRoutes.list(ctx) - }) - - test('returns 500 for unexpected error', async (): Promise => { - const outgoingPaymentService = await deps.use('outgoingPaymentService') - jest - .spyOn(outgoingPaymentService, 'getPaymentPointerPage') - .mockRejectedValueOnce(new Error('unexpected')) - const ctx = createContext({ - headers: { Accept: 'application/json' } - }) - ctx.paymentPointer = paymentPointer - await expect(outgoingPaymentRoutes.list(ctx)).rejects.toMatchObject({ - status: 500, - message: `Error trying to list outgoing payments` - }) - }) - }) - }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.ts b/packages/backend/src/open_payments/payment/outgoing/routes.ts index f8d083f3a0..70edff99f6 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.ts @@ -4,12 +4,7 @@ import { IAppConfig } from '../../../config/app' import { OutgoingPaymentService } from './service' import { isOutgoingPaymentError, errorToCode, errorToMessage } from './errors' import { OutgoingPayment, OutgoingPaymentState } from './model' -import { - getPageInfo, - parsePaginationQueryParameters -} from '../../../shared/pagination' -import { Pagination } from '../../../shared/baseModel' -import { AccessAction } from '../../auth/grant' +import { listSubresource } from '../../payment_pointer/routes' interface ServiceDependencies { config: IAppConfig @@ -43,20 +38,12 @@ async function getOutgoingPayment( ctx: ReadContext ): Promise { let outgoingPayment: OutgoingPayment | undefined - let clientId = undefined - const outgoingAccess = ctx.grant?.access.filter( - (access) => access.type === 'outgoing-payment' - ) - if (outgoingAccess && outgoingAccess.length === 1) { - clientId = outgoingAccess[0].actions.includes(AccessAction.ReadAll) - ? undefined - : ctx.grant.clientId - } try { - outgoingPayment = await deps.outgoingPaymentService.get( - ctx.params.id, - clientId - ) + outgoingPayment = await deps.outgoingPaymentService.get({ + id: ctx.params.id, + clientId: ctx.clientId, + paymentPointerId: ctx.paymentPointer.id + }) } catch (_) { ctx.throw(500, 'Error trying to get outgoing payment') } @@ -105,38 +92,12 @@ async function listOutgoingPayments( deps: ServiceDependencies, ctx: ListContext ): Promise { - const pagination = parsePaginationQueryParameters(ctx.request.query) - let clientId = undefined - const outgoingAccess = ctx.grant?.access.filter( - (access) => access.type === 'outgoing-payment' - ) - if (outgoingAccess && outgoingAccess.length === 1) { - clientId = outgoingAccess[0].actions.includes(AccessAction.ListAll) - ? undefined - : ctx.grant.clientId - } try { - const page = await deps.outgoingPaymentService.getPaymentPointerPage( - ctx.paymentPointer.id, - pagination, - clientId - ) - const pageInfo = await getPageInfo( - (pagination: Pagination) => - deps.outgoingPaymentService.getPaymentPointerPage( - ctx.paymentPointer.id, - pagination - ), - page - ) - const result = { - pagination: pageInfo, - result: page.map((item: OutgoingPayment) => { - item.paymentPointer = ctx.paymentPointer - return outgoingPaymentToBody(deps, item) - }) - } - ctx.body = result + await listSubresource({ + ctx, + getPaymentPointerPage: deps.outgoingPaymentService.getPaymentPointerPage, + toBody: (payment) => outgoingPaymentToBody(deps, payment) + }) } catch (_) { ctx.throw(500, 'Error trying to list outgoing payments') } diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index 2c5734984f..33e3097ee0 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -14,6 +14,7 @@ import { CreateOutgoingPaymentOptions, OutgoingPaymentService } from './service' import { createTestApp, TestContainer } from '../../../tests/app' import { Config } from '../../../config/app' import { CreateQuoteOptions } from '../../quote/service' +import { createGrant } from '../../../tests/grant' import { createIncomingPayment } from '../../../tests/incomingPayment' import { createOutgoingPayment } from '../../../tests/outgoingPayment' import { @@ -40,8 +41,7 @@ import { AccountingService, TransferOptions } from '../../../accounting/service' import { AssetOptions } from '../../../asset/service' import { Amount } from '../../amount' import { ConnectionService } from '../../connection/service' -import { Pagination } from '../../../shared/baseModel' -import { getPageTests } from '../../../shared/baseModel.test' +import { getTests } from '../../payment_pointer/model.test' import { AccessAction, AccessType, Grant } from '../../auth/grant' import { GrantReference } from '../../grantReference/model' import { Quote } from '../../quote/model' @@ -94,7 +94,9 @@ describe('OutgoingPaymentService', (): void => { expectedError?: string ): Promise { await expect(outgoingPaymentService.processNext()).resolves.toBe(paymentId) - const payment = await outgoingPaymentService.get(paymentId) + const payment = await outgoingPaymentService.get({ + id: paymentId + }) if (!payment) throw 'no payment' if (expectState) expect(payment.state).toBe(expectState) expect(payment.error).toEqual(expectedError || null) @@ -284,9 +286,28 @@ describe('OutgoingPaymentService', (): void => { await appContainer.shutdown() }) - describe('get', (): void => { - it('returns undefined when no payment exists', async () => { - await expect(outgoingPaymentService.get(uuid())).resolves.toBeUndefined() + describe('get/getPaymentPointerPage', (): void => { + getTests({ + createGrant: async ({ clientId }) => + createGrant(deps, { + clientId, + access: [ + { + type: AccessType.OutgoingPayment, + actions: [AccessAction.Create, AccessAction.Read] + } + ] + }), + createModel: ({ grant }: { grant?: Grant }) => + createOutgoingPayment(deps, { + paymentPointerId, + grant, + receiver, + sendAmount, + validDestination: false + }), + get: (options) => outgoingPaymentService.get(options), + list: (options) => outgoingPaymentService.getPaymentPointerPage(options) }) }) @@ -341,9 +362,11 @@ describe('OutgoingPaymentService', (): void => { peerId: outgoingPeer ? peer.id : null }) - await expect(outgoingPaymentService.get(payment.id)).resolves.toEqual( - payment - ) + await expect( + outgoingPaymentService.get({ + id: payment.id + }) + ).resolves.toEqual(payment) const expectedPaymentData: Partial = { id: payment.id @@ -1162,7 +1185,9 @@ describe('OutgoingPaymentService', (): void => { state: OutgoingPaymentState.Sending }) - const after = await outgoingPaymentService.get(payment.id) + const after = await outgoingPaymentService.get({ + id: payment.id + }) expect(after?.state).toBe(OutgoingPaymentState.Sending) await expectOutcome(payment, { accountBalance: quoteAmount }) }) @@ -1176,7 +1201,9 @@ describe('OutgoingPaymentService', (): void => { }) ).resolves.toEqual(FundingError.InvalidAmount) - const after = await outgoingPaymentService.get(payment.id) + const after = await outgoingPaymentService.get({ + id: payment.id + }) expect(after?.state).toBe(OutgoingPaymentState.Funding) await expectOutcome(payment, { accountBalance: BigInt(0) }) }) @@ -1193,72 +1220,11 @@ describe('OutgoingPaymentService', (): void => { }) ).resolves.toEqual(FundingError.WrongState) - const after = await outgoingPaymentService.get(payment.id) + const after = await outgoingPaymentService.get({ + id: payment.id + }) expect(after?.state).toBe(startState) }) }) }) - - describe.each` - client | description - ${false} | ${'without client'} - ${true} | ${'with client'} - `('Outgoing payment pagination - $description', ({ client }): void => { - let grant: Grant - beforeEach(async (): Promise => { - grant = new Grant({ - active: true, - clientId: grantRef.clientId, - grant: grantRef.id, - access: [ - { - type: AccessType.OutgoingPayment, - actions: [AccessAction.Create, AccessAction.Read] - } - ] - }) - if (client) { - const secondGrant = new Grant({ - active: true, - clientId: uuid(), - grant: uuid(), - access: [ - { - type: AccessType.OutgoingPayment, - actions: [AccessAction.Create, AccessAction.Read] - } - ] - }) - await grantReferenceService.create({ - id: secondGrant.grant, - clientId: secondGrant.clientId - }) - for (let i = 0; i < 10; i++) { - await createOutgoingPayment(deps, { - paymentPointerId, - grant: secondGrant, - receiver, - sendAmount, - validDestination: false - }) - } - } - }) - getPageTests({ - createModel: () => - createOutgoingPayment(deps, { - paymentPointerId, - grant: grant, - receiver, - sendAmount, - validDestination: false - }), - getPage: (pagination: Pagination) => - outgoingPaymentService.getPaymentPointerPage( - paymentPointerId, - pagination, - client ? grant.clientId : undefined - ) - }) - }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 75263f139b..e5066e33ca 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -1,7 +1,6 @@ import assert from 'assert' import { ForeignKeyViolationError, TransactionOrKnex } from 'objection' -import { Pagination } from '../../../shared/baseModel' import { BaseService } from '../../../shared/baseService' import { FundingError, @@ -17,6 +16,8 @@ import { AccountingService } from '../../../accounting/service' import { PeerService } from '../../../peer/service' import { Grant, AccessLimits, getInterval } from '../../auth/grant' import { OpenPaymentsClientService } from '../../client/service' +import { GetOptions, ListOptions } from '../../payment_pointer/model' +import { PaymentPointerSubresourceService } from '../../payment_pointer/service' import { IlpPlugin, IlpPluginOptions } from '../../../shared/ilp_plugin' import { sendWebhookEvent } from './lifecycle' import * as worker from './worker' @@ -28,8 +29,8 @@ import { Interval } from 'luxon' import { knex } from 'knex' import { GrantReferenceService } from '../../grantReference/service' -export interface OutgoingPaymentService { - get(id: string, clientId?: string): Promise +export interface OutgoingPaymentService + extends PaymentPointerSubresourceService { create( options: CreateOutgoingPaymentOptions ): Promise @@ -37,11 +38,6 @@ export interface OutgoingPaymentService { options: FundOutgoingPaymentOptions ): Promise processNext(): Promise - getPaymentPointerPage( - paymentPointerId: string, - pagination?: Pagination, - clientId?: string - ): Promise } export interface ServiceDependencies extends BaseService { @@ -61,32 +57,22 @@ export async function createOutgoingPaymentService( logger: deps_.logger.child({ service: 'OutgoingPaymentService' }) } return { - get: (id, clientId) => getOutgoingPayment(deps, id, clientId), + get: (options) => getOutgoingPayment(deps, options), create: (options: CreateOutgoingPaymentOptions) => createOutgoingPayment(deps, options), fund: (options) => fundPayment(deps, options), processNext: () => worker.processPendingPayment(deps), - getPaymentPointerPage: (paymentPointerId, pagination, clientId) => - getPaymentPointerPage(deps, paymentPointerId, pagination, clientId) + getPaymentPointerPage: (options) => getPaymentPointerPage(deps, options) } } async function getOutgoingPayment( deps: ServiceDependencies, - id: string, - clientId?: string + options: GetOptions ): Promise { - let outgoingPayment: OutgoingPayment - if (!clientId) { - outgoingPayment = await OutgoingPayment.query(deps.knex) - .findById(id) - .withGraphJoined('quote.asset') - } else { - outgoingPayment = await OutgoingPayment.query(deps.knex) - .findById(id) - .withGraphJoined('[quote.asset, grantRef]') - .where('grantRef.clientId', clientId) - } + const outgoingPayment = await OutgoingPayment.query(deps.knex) + .get(options) + .withGraphFetched('quote.asset') if (outgoingPayment) return await addSentAmount(deps, outgoingPayment) else return } @@ -370,26 +356,11 @@ async function fundPayment( async function getPaymentPointerPage( deps: ServiceDependencies, - paymentPointerId: string, - pagination?: Pagination, - clientId?: string + options: ListOptions ): Promise { - let page: OutgoingPayment[] - if (!clientId) { - page = await OutgoingPayment.query(deps.knex) - .getPage(pagination) - .where({ - paymentPointerId - }) - .withGraphFetched('quote.asset') - } else { - page = await OutgoingPayment.query(deps.knex) - .getPage(pagination) - .where('outgoingPayments.paymentPointerId', paymentPointerId) - .andWhere('grantRef.clientId', clientId) - .withGraphJoined('[quote.asset, grantRef]') - } - + const page = await OutgoingPayment.query(deps.knex) + .list(options) + .withGraphFetched('quote.asset') const amounts = await deps.accountingService.getAccountsTotalSent( page.map((payment: OutgoingPayment) => payment.id) ) diff --git a/packages/backend/src/open_payments/payment_pointer/model.test.ts b/packages/backend/src/open_payments/payment_pointer/model.test.ts new file mode 100644 index 0000000000..a8cbeb6bdf --- /dev/null +++ b/packages/backend/src/open_payments/payment_pointer/model.test.ts @@ -0,0 +1,330 @@ +import * as httpMocks from 'node-mocks-http' +import { v4 as uuid } from 'uuid' + +import { + PaymentPointer, + PaymentPointerSubresource, + GetOptions, + ListOptions +} from './model' +import { Grant } from '../auth/grant' +import { PaymentPointerContext, ReadContext, ListContext } from '../../app' +import { getPageTests } from '../../shared/baseModel.test' +import { createContext } from '../../tests/context' + +interface SetupOptions { + reqOpts: httpMocks.RequestOptions + params?: Record + paymentPointer: PaymentPointer + grant?: Grant + clientId?: string +} + +export const setup = ( + options: SetupOptions +): T => { + const ctx = createContext( + { + ...options.reqOpts, + headers: Object.assign( + { Accept: 'application/json', 'Content-Type': 'application/json' }, + options.reqOpts.headers + ) + }, + options.params + ) + if (options.reqOpts.body !== undefined) { + ctx.request.body = options.reqOpts.body + } + ctx.paymentPointer = options.paymentPointer + ctx.grant = options.grant + ctx.clientId = options.clientId + return ctx +} + +interface BaseTestsOptions { + createGrant: (options: { clientId: string }) => Promise + createModel: (options: { grant?: Grant }) => Promise + testGet: (options: GetOptions, expectedMatch?: M) => void + testList?: (options: ListOptions, expectedMatch?: M) => void +} + +const baseGetTests = ({ + createGrant, + createModel, + testGet, + testList +}: BaseTestsOptions): void => { + enum GetOption { + Matching = 'matching', + Conflicting = 'conflicting', + Unspecified = 'unspecified' + } + + describe.each` + withGrant | description + ${true} | ${'with grant'} + ${false} | ${'without grant'} + `( + 'Common PaymentPointerSubresource get/getPaymentPointerPage ($description)', + ({ withGrant }): void => { + const grantClientId = uuid() + + describe.each` + clientId | match | description + ${grantClientId} | ${true} | ${GetOption.Matching} + ${uuid()} | ${false} | ${GetOption.Conflicting} + ${undefined} | ${true} | ${GetOption.Unspecified} + `('$description clientId', ({ clientId, match, description }): void => { + // Do not test matching clientId if model has no grant + if (withGrant || description !== GetOption.Matching) { + let model: M + + // This beforeEach needs to be inside the above if statement to avoid: + // Invalid: beforeEach() may not be used in a describe block containing no tests. + beforeEach(async (): Promise => { + model = await createModel({ + grant: withGrant + ? await createGrant({ + clientId: grantClientId + }) + : undefined + }) + }) + describe.each` + match | description + ${match} | ${GetOption.Matching} + ${false} | ${GetOption.Conflicting} + ${match} | ${GetOption.Unspecified} + `('$description paymentPointerId', ({ match, description }): void => { + let paymentPointerId: string + beforeEach((): void => { + switch (description) { + case GetOption.Matching: + paymentPointerId = model.paymentPointerId + break + case GetOption.Conflicting: + paymentPointerId = uuid() + break + case GetOption.Unspecified: + paymentPointerId = undefined + break + } + }) + describe.each` + match | description + ${match} | ${GetOption.Matching} + ${false} | ${GetOption.Conflicting} + `('$description id', ({ match, description }): void => { + let id: string + beforeEach((): void => { + id = description === GetOption.Matching ? model.id : uuid() + }) + + test(`${ + match ? '' : 'cannot ' + }get a model`, async (): Promise => { + await testGet( + { + id, + clientId, + paymentPointerId + }, + match ? model : undefined + ) + }) + }) + test(`${ + match ? '' : 'cannot ' + }list model`, async (): Promise => { + if (testList && paymentPointerId) { + await testList( + { + paymentPointerId, + clientId + }, + match ? model : undefined + ) + } + }) + }) + } + }) + } + ) +} + +type TestsOptions = Omit, 'testGet' | 'testList'> & { + get: (options: GetOptions) => Promise + list: (options: ListOptions) => Promise +} + +export const getTests = ({ + createGrant, + createModel, + get, + list +}: TestsOptions): void => { + baseGetTests({ + createGrant, + createModel, + testGet: (options, expectedMatch) => + expect(get(options)).resolves.toEqual(expectedMatch), + // tests paymentPointerId / clientId filtering + testList: (options, expectedMatch) => + expect(list(options)).resolves.toEqual([expectedMatch]) + }) + + // tests pagination + let paymentPointerId: string + getPageTests({ + createModel: async () => { + const model = await createModel({}) + paymentPointerId = model.paymentPointerId + return model + }, + getPage: (pagination) => + list({ + paymentPointerId, + pagination + }) + }) +} + +type RouteTestsOptions = Omit< + BaseTestsOptions, + 'testGet' | 'testList' +> & { + getPaymentPointer: () => Promise + get: (ctx: ReadContext) => Promise + getBody: (model: M, list?: boolean) => Record + list?: (ctx: ListContext) => Promise + urlPath: string +} + +export const getRouteTests = ({ + createGrant, + getPaymentPointer, + createModel, + get, + getBody, + list, + urlPath +}: RouteTestsOptions): void => { + const testList = async ({ paymentPointerId, clientId }, expectedMatch) => { + const paymentPointer = await getPaymentPointer() + paymentPointer.id = paymentPointerId + const ctx = setup({ + reqOpts: { + headers: { Accept: 'application/json' }, + method: 'GET', + url: urlPath + }, + paymentPointer, + clientId + }) + await expect(list(ctx)).resolves.toBeUndefined() + if (expectedMatch) { + // TODO: https://github.com/interledger/open-payments/issues/191 + expect(ctx.response).toSatisfyApiSpec() + } + expect(ctx.body).toEqual({ + result: expectedMatch ? [getBody(expectedMatch, true)] : [], + pagination: { + hasPreviousPage: false, + hasNextPage: false, + startCursor: expectedMatch?.id, + endCursor: expectedMatch?.id + } + }) + } + + baseGetTests({ + createGrant, + createModel, + testGet: async ({ id, paymentPointerId, clientId }, expectedMatch) => { + const paymentPointer = await getPaymentPointer() + paymentPointer.id = paymentPointerId + const ctx = setup({ + reqOpts: { + headers: { Accept: 'application/json' }, + method: 'GET', + url: `${urlPath}/${id}` + }, + params: { + id + }, + paymentPointer, + clientId + }) + if (expectedMatch) { + await expect(get(ctx)).resolves.toBeUndefined() + expect(ctx.response).toSatisfyApiSpec() + expect(ctx.body).toEqual(getBody(expectedMatch)) + } else { + await expect(get(ctx)).rejects.toMatchObject({ + status: 404, + message: 'Not Found' + }) + } + }, + // tests paymentPointerId / clientId filtering + testList: list && testList + }) + + if (list) { + describe('Common list route pagination', (): void => { + let models: M[] + + beforeEach(async (): Promise => { + models = [] + for (let i = 0; i < 3; i++) { + models.push(await createModel({})) + } + }) + + test.each` + query | cursorIndex | pagination | startIndex | endIndex | description + ${{}} | ${-1} | ${{ hasPreviousPage: false, hasNextPage: false }} | ${0} | ${2} | ${'no pagination parameters'} + ${{ first: 2 }} | ${-1} | ${{ hasPreviousPage: false, hasNextPage: true }} | ${0} | ${1} | ${'only `first`'} + ${{ first: 10 }} | ${0} | ${{ hasPreviousPage: true, hasNextPage: false }} | ${1} | ${2} | ${'`first` plus `cursor`'} + ${{ last: 10 }} | ${2} | ${{ hasPreviousPage: false, hasNextPage: true }} | ${0} | ${1} | ${'`last` plus `cursor`'} + `( + 'returns 200 on $description', + async ({ + query, + cursorIndex, + pagination, + startIndex, + endIndex + }): Promise => { + const cursor = models[cursorIndex]?.id + if (cursor) { + query['cursor'] = cursor + } + pagination['startCursor'] = models[startIndex].id + pagination['endCursor'] = models[endIndex].id + const ctx = setup({ + reqOpts: { + headers: { Accept: 'application/json' }, + method: 'GET', + query, + url: urlPath + }, + paymentPointer: await getPaymentPointer() + }) + await expect(list(ctx)).resolves.toBeUndefined() + expect(ctx.response).toSatisfyApiSpec() + expect(ctx.body).toEqual({ + pagination, + result: models + .slice(startIndex, endIndex + 1) + .map((model) => getBody(model, true)) + }) + } + ) + }) + } +} + +test.todo('test suite must contain at least one test') diff --git a/packages/backend/src/open_payments/payment_pointer/model.ts b/packages/backend/src/open_payments/payment_pointer/model.ts index b637a6be31..229a999b56 100644 --- a/packages/backend/src/open_payments/payment_pointer/model.ts +++ b/packages/backend/src/open_payments/payment_pointer/model.ts @@ -1,9 +1,9 @@ -import { Model } from 'objection' - +import { Model, Page } from 'objection' +import { GrantReference } from '../grantReference/model' import { LiquidityAccount, OnCreditOptions } from '../../accounting/service' import { ConnectorAccount } from '../../connector/core/rafiki' import { Asset } from '../../asset/model' -import { BaseModel } from '../../shared/baseModel' +import { BaseModel, Pagination } from '../../shared/baseModel' import { WebhookEvent } from '../../webhook/model' import { PaymentPointerKey } from '../../paymentPointerKey/model' @@ -102,3 +102,97 @@ export class PaymentPointerEvent extends WebhookEvent { public type!: PaymentPointerEventType public data!: PaymentPointerData } + +export interface GetOptions { + id: string + clientId?: string + paymentPointerId?: string +} + +export interface ListOptions { + paymentPointerId: string + clientId?: string + pagination?: Pagination +} + +class SubresourceQueryBuilder< + M extends Model, + R = M[] +> extends BaseModel.QueryBuilder { + ArrayQueryBuilderType!: SubresourceQueryBuilder + SingleQueryBuilderType!: SubresourceQueryBuilder + MaybeSingleQueryBuilderType!: SubresourceQueryBuilder + NumberQueryBuilderType!: SubresourceQueryBuilder + PageQueryBuilderType!: SubresourceQueryBuilder> + + private byClientId(clientId: string) { + // SubresourceQueryBuilder seemingly cannot access + // PaymentPointerSubresource relationMappings, so + // this.withGraphJoined('grantRef') results in: + // missing FROM-clause entry for table "grantRef" + return this.join( + 'grantReferences as grantRef', + `${this.modelClass().tableName}.grantId`, + 'grantRef.id' + ).where('grantRef.clientId', clientId) + } + + get({ id, paymentPointerId, clientId }: GetOptions) { + if (paymentPointerId) { + this.where( + `${this.modelClass().tableName}.paymentPointerId`, + paymentPointerId + ) + } + if (clientId) { + this.byClientId(clientId) + } + return this.findById(id) + } + list({ paymentPointerId, clientId, pagination }: ListOptions) { + if (clientId) { + this.byClientId(clientId) + } + return this.getPage(pagination).where( + `${this.modelClass().tableName}.paymentPointerId`, + paymentPointerId + ) + } +} + +export abstract class PaymentPointerSubresource extends BaseModel { + public static readonly urlPath: string + + public readonly paymentPointerId!: string + public paymentPointer?: PaymentPointer + + public abstract readonly assetId: string + public abstract asset: Asset + + public readonly grantId?: string + public grantRef?: GrantReference + + static get relationMappings() { + return { + paymentPointer: { + relation: Model.BelongsToOneRelation, + modelClass: PaymentPointer, + join: { + from: `${this.tableName}.paymentPointerId`, + to: 'paymentPointers.id' + } + }, + grantRef: { + relation: Model.HasOneRelation, + modelClass: GrantReference, + join: { + from: `${this.tableName}.grantId`, + to: 'grantReferences.id' + } + } + } + } + + QueryBuilderType!: SubresourceQueryBuilder + static QueryBuilder = SubresourceQueryBuilder +} diff --git a/packages/backend/src/open_payments/payment_pointer/routes.ts b/packages/backend/src/open_payments/payment_pointer/routes.ts index c042cfe941..64b6ced9bb 100644 --- a/packages/backend/src/open_payments/payment_pointer/routes.ts +++ b/packages/backend/src/open_payments/payment_pointer/routes.ts @@ -1,5 +1,11 @@ -import { PaymentPointerContext } from '../../app' +import { PaymentPointerSubresource } from './model' +import { PaymentPointerSubresourceService } from './service' +import { PaymentPointerContext, ListContext } from '../../app' import { IAppConfig } from '../../config/app' +import { + getPageInfo, + parsePaginationQueryParameters +} from '../../shared/pagination' interface ServiceDependencies { config: IAppConfig @@ -34,3 +40,39 @@ export async function getPaymentPointer( authServer: deps.config.authServerGrantUrl } } + +interface ListSubresourceOptions { + ctx: ListContext + getPaymentPointerPage?: PaymentPointerSubresourceService['getPaymentPointerPage'] + toBody: (model: M) => Record +} + +export const listSubresource = async ({ + ctx, + getPaymentPointerPage, + toBody +}: ListSubresourceOptions) => { + const pagination = parsePaginationQueryParameters(ctx.request.query) + const page = await getPaymentPointerPage({ + paymentPointerId: ctx.paymentPointer.id, + pagination, + clientId: ctx.clientId + }) + const pageInfo = await getPageInfo( + (pagination) => + getPaymentPointerPage({ + paymentPointerId: ctx.paymentPointer.id, + pagination, + clientId: ctx.clientId + }), + page + ) + const result = { + pagination: pageInfo, + result: page.map((item: M) => { + item.paymentPointer = ctx.paymentPointer + return toBody(item) + }) + } + ctx.body = result +} diff --git a/packages/backend/src/open_payments/payment_pointer/service.ts b/packages/backend/src/open_payments/payment_pointer/service.ts index fb34c1531e..12841ab67d 100644 --- a/packages/backend/src/open_payments/payment_pointer/service.ts +++ b/packages/backend/src/open_payments/payment_pointer/service.ts @@ -5,7 +5,10 @@ import { PaymentPointerError } from './errors' import { PaymentPointer, PaymentPointerEvent, - PaymentPointerEventType + PaymentPointerEventType, + GetOptions, + ListOptions, + PaymentPointerSubresource } from './model' import { BaseService } from '../../shared/baseService' import { AccountingService } from '../../accounting/service' @@ -208,3 +211,15 @@ async function createWithdrawalEvent( totalEventsAmount: paymentPointer.totalEventsAmount + amount }) } + +export interface CreateSubresourceOptions { + paymentPointerId: string +} + +export interface PaymentPointerSubresourceService< + M extends PaymentPointerSubresource +> { + get(options: GetOptions): Promise + create(options: { paymentPointerId: string }): Promise + getPaymentPointerPage(options: ListOptions): Promise +} diff --git a/packages/backend/src/open_payments/quote/model.ts b/packages/backend/src/open_payments/quote/model.ts index ba2c910335..8abaf8cb4e 100644 --- a/packages/backend/src/open_payments/quote/model.ts +++ b/packages/backend/src/open_payments/quote/model.ts @@ -3,12 +3,12 @@ import { Model, Pojo } from 'objection' import * as Pay from '@interledger/pay' import { Amount, AmountJSON } from '../amount' -import { PaymentPointer } from '../payment_pointer/model' +import { PaymentPointerSubresource } from '../payment_pointer/model' import { Asset } from '../../asset/model' -import { BaseModel } from '../../shared/baseModel' -export class Quote extends BaseModel { +export class Quote extends PaymentPointerSubresource { public static readonly tableName = 'quotes' + public static readonly urlPath = '/quotes' static get virtualAttributes(): string[] { return [ @@ -20,29 +20,20 @@ export class Quote extends BaseModel { ] } - // Open payments payment pointer id of the sender - public paymentPointerId!: string - public paymentPointer?: PaymentPointer - // Asset id of the sender public assetId!: string public asset!: Asset - static relationMappings = { - paymentPointer: { - relation: Model.BelongsToOneRelation, - modelClass: PaymentPointer, - join: { - from: 'quotes.paymentPointerId', - to: 'paymentPointers.id' - } - }, - asset: { - relation: Model.HasOneRelation, - modelClass: Asset, - join: { - from: 'quotes.assetId', - to: 'assets.id' + static get relationMappings() { + return { + ...super.relationMappings, + asset: { + relation: Model.HasOneRelation, + modelClass: Asset, + join: { + from: 'quotes.assetId', + to: 'assets.id' + } } } } diff --git a/packages/backend/src/open_payments/quote/routes.test.ts b/packages/backend/src/open_payments/quote/routes.test.ts index 8113054591..ab29bfb141 100644 --- a/packages/backend/src/open_payments/quote/routes.test.ts +++ b/packages/backend/src/open_payments/quote/routes.test.ts @@ -9,14 +9,16 @@ import { createContext } from '../../tests/context' import { createTestApp, TestContainer } from '../../tests/app' import { Config, IAppConfig } from '../../config/app' import { initIocContainer } from '../..' -import { AppServices, CreateContext, ReadContext } from '../../app' +import { AppServices, CreateContext } from '../../app' import { truncateTables } from '../../tests/tableManager' import { QuoteService } from './service' import { Quote } from './model' import { QuoteRoutes, CreateBody } from './routes' -import { Amount } from '../amount' +import { Amount, serializeAmount } from '../amount' import { PaymentPointer } from '../payment_pointer/model' +import { getRouteTests } from '../payment_pointer/model.test' import { randomAsset } from '../../tests/asset' +import { createGrant } from '../../tests/grant' import { createPaymentPointer } from '../../tests/paymentPointer' import { createQuote } from '../../tests/quote' @@ -37,9 +39,13 @@ describe('Quote Routes', (): void => { assetScale: asset.scale } - const createPaymentPointerQuote = async ( + const createPaymentPointerQuote = async ({ + paymentPointerId, + grantId + }: { paymentPointerId: string - ): Promise => { + grantId: string + }): Promise => { return await createQuote(deps, { paymentPointerId, receiver, @@ -48,6 +54,7 @@ describe('Quote Routes', (): void => { assetCode: asset.code, assetScale: asset.scale }, + grantId, validDestination: false }) } @@ -81,49 +88,25 @@ describe('Quote Routes', (): void => { }) describe('get', (): void => { - test('returns 404 for nonexistent quote', async (): Promise => { - const ctx = createContext( - { - headers: { Accept: 'application/json' } - }, - { - id: uuid() - } - ) - ctx.paymentPointer = paymentPointer - await expect(quoteRoutes.get(ctx)).rejects.toHaveProperty('status', 404) - }) - - test('returns 200 with a quote', async (): Promise => { - const quote = await createPaymentPointerQuote(paymentPointer.id) - const ctx = createContext( - { - headers: { Accept: 'application/json' }, - method: 'GET', - url: `/quotes/${quote.id}` - }, - { - id: quote.id - } - ) - ctx.paymentPointer = paymentPointer - await expect(quoteRoutes.get(ctx)).resolves.toBeUndefined() - expect(ctx.response).toSatisfyApiSpec() - expect(ctx.body).toEqual({ + getRouteTests({ + createGrant: async (options) => createGrant(deps, options), + getPaymentPointer: async () => paymentPointer, + createModel: async ({ grant }) => + createPaymentPointerQuote({ + paymentPointerId: paymentPointer.id, + grantId: grant?.grant + }), + get: (ctx) => quoteRoutes.get(ctx), + getBody: (quote) => ({ id: `${paymentPointer.url}/quotes/${quote.id}`, paymentPointer: paymentPointer.url, receiver: quote.receiver, - sendAmount: { - ...quote.sendAmount, - value: quote.sendAmount.value.toString() - }, - receiveAmount: { - ...quote.receiveAmount, - value: quote.receiveAmount.value.toString() - }, + sendAmount: serializeAmount(quote.sendAmount), + receiveAmount: serializeAmount(quote.receiveAmount), createdAt: quote.createdAt.toISOString(), expiresAt: quote.expiresAt.toISOString() - }) + }), + urlPath: Quote.urlPath }) }) diff --git a/packages/backend/src/open_payments/quote/routes.ts b/packages/backend/src/open_payments/quote/routes.ts index 2204ecf5d2..c271d87af4 100644 --- a/packages/backend/src/open_payments/quote/routes.ts +++ b/packages/backend/src/open_payments/quote/routes.ts @@ -32,7 +32,11 @@ async function getQuote( deps: ServiceDependencies, ctx: ReadContext ): Promise { - const quote = await deps.quoteService.get(ctx.params.id) + const quote = await deps.quoteService.get({ + id: ctx.params.id, + clientId: ctx.clientId, + paymentPointerId: ctx.paymentPointer.id + }) if (!quote) return ctx.throw(404) quote.paymentPointer = ctx.paymentPointer const body = quoteToBody(deps, quote) diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index 57da906070..5389a0264f 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -6,7 +6,6 @@ import { URL } from 'url' import { v4 as uuid } from 'uuid' import { QuoteError, isQuoteError } from './errors' -import { Quote } from './model' import { QuoteService, CreateQuoteOptions, @@ -17,11 +16,13 @@ import { IAppConfig, Config } from '../../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../' import { AppServices } from '../../app' +import { createGrant } from '../../tests/grant' import { createIncomingPayment } from '../../tests/incomingPayment' import { createPaymentPointer, MockPaymentPointer } from '../../tests/paymentPointer' +import { createQuote } from '../../tests/quote' import { truncateTables } from '../../tests/tableManager' import { AssetOptions } from '../../asset/service' import { Amount, AmountJSON, serializeAmount } from '../amount' @@ -29,8 +30,7 @@ import { IncomingPayment, IncomingPaymentState } from '../payment/incoming/model' -import { Pagination } from '../../shared/baseModel' -import { getPageTests } from '../../shared/baseModel.test' +import { getTests } from '../payment_pointer/model.test' import { GrantReference } from '../grantReference/model' import { GrantReferenceService } from '../grantReference/service' @@ -40,7 +40,6 @@ describe('QuoteService', (): void => { let quoteService: QuoteService let knex: Knex let paymentPointerId: string - let assetId: string let receivingPaymentPointer: MockPaymentPointer let config: IAppConfig let quoteUrl: URL @@ -98,7 +97,6 @@ describe('QuoteService', (): void => { } }) paymentPointerId = paymentPointer.id - assetId = paymentPointer.assetId receivingPaymentPointer = await createPaymentPointer(deps, { asset: destinationAsset, mockServerPort: appContainer.openPaymentsPort @@ -127,9 +125,25 @@ describe('QuoteService', (): void => { await appContainer.shutdown() }) - describe('get', (): void => { - it('returns undefined when no quote exists', async () => { - await expect(quoteService.get(uuid())).resolves.toBeUndefined() + describe('get/getPaymentPointerPage', (): void => { + getTests({ + createGrant: async (options) => createGrant(deps, options), + createModel: ({ grant }) => + createQuote(deps, { + paymentPointerId, + receiver: `${ + receivingPaymentPointer.url + }/incoming-payments/${uuid()}`, + sendAmount: { + value: BigInt(56), + assetCode: asset.code, + assetScale: asset.scale + }, + grantId: grant?.grant, + validDestination: false + }), + get: (options) => quoteService.get(options), + list: (options) => quoteService.getPaymentPointerPage(options) }) }) @@ -226,6 +240,7 @@ describe('QuoteService', (): void => { let options: CreateQuoteOptions let incomingPayment: IncomingPayment let expected: ExpectedQuote + const grantId = uuid() beforeEach(async (): Promise => { incomingPayment = await createIncomingPayment(deps, { @@ -246,6 +261,11 @@ describe('QuoteService', (): void => { ...options, paymentType } + + await grantReferenceService.create({ + id: grantId, + clientId: uuid() + }) }) if (!sendAmount && !receiveAmount && !incomingAmount) { @@ -256,52 +276,68 @@ describe('QuoteService', (): void => { }) } else { if (sendAmount || receiveAmount) { - it('creates a Quote', async () => { - const walletScope = mockWalletQuote({ - expected - }) - const quote = await quoteService.create(options) - assert.ok(!isQuoteError(quote)) - walletScope.isDone() - expect(quote).toMatchObject({ - paymentPointerId, - receiver: options.receiver, - sendAmount: sendAmount || { - value: BigInt( - Math.ceil( - Number(receiveAmount.value) / - quote.minExchangeRate.valueOf() - ) - ), - assetCode: asset.code, - assetScale: asset.scale - }, - receiveAmount: receiveAmount || { - value: BigInt( - Math.ceil( - Number(sendAmount.value) * quote.minExchangeRate.valueOf() - ) + it.each` + grantId | description + ${grantId} | ${'with a grantId'} + ${undefined} | ${'without a grantId'} + `( + 'creates a Quote $description', + async ({ grantId }): Promise => { + const walletScope = mockWalletQuote({ + expected + }) + const quote = await quoteService.create({ + ...options, + grantId + }) + assert.ok(!isQuoteError(quote)) + walletScope.isDone() + expect(quote).toMatchObject({ + paymentPointerId, + receiver: options.receiver, + sendAmount: sendAmount || { + value: BigInt( + Math.ceil( + Number(receiveAmount.value) / + quote.minExchangeRate.valueOf() + ) + ), + assetCode: asset.code, + assetScale: asset.scale + }, + receiveAmount: receiveAmount || { + value: BigInt( + Math.ceil( + Number(sendAmount.value) * + quote.minExchangeRate.valueOf() + ) + ), + assetCode: destinationAsset.code, + assetScale: destinationAsset.scale + }, + maxPacketAmount: BigInt('9223372036854775807'), + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + expiresAt: new Date( + quote.createdAt.getTime() + config.quoteLifespan ), - assetCode: destinationAsset.code, - assetScale: destinationAsset.scale - }, - maxPacketAmount: BigInt('9223372036854775807'), - createdAt: expect.any(Date), - updatedAt: expect.any(Date), - expiresAt: new Date( - quote.createdAt.getTime() + config.quoteLifespan + grantId: grantId || null + }) + expect(quote.minExchangeRate.valueOf()).toBe( + 0.5 * (1 - config.slippage) + ) + expect(quote.lowEstimatedExchangeRate.valueOf()).toBe(0.5) + expect(quote.highEstimatedExchangeRate.valueOf()).toBe( + 0.500000000001 ) - }) - expect(quote.minExchangeRate.valueOf()).toBe( - 0.5 * (1 - config.slippage) - ) - expect(quote.lowEstimatedExchangeRate.valueOf()).toBe(0.5) - expect(quote.highEstimatedExchangeRate.valueOf()).toBe( - 0.500000000001 - ) - await expect(quoteService.get(quote.id)).resolves.toEqual(quote) - }) + await expect( + quoteService.get({ + id: quote.id + }) + ).resolves.toEqual(quote) + } + ) if (incomingAmount) { it('fails if receiveAmount exceeds receiver.incomingAmount', async (): Promise => { @@ -325,42 +361,57 @@ describe('QuoteService', (): void => { } } else { if (incomingAmount) { - it('creates a Quote', async () => { - const scope = mockWalletQuote({ - expected - }) - const quote = await quoteService.create(options) - scope.isDone() - assert.ok(!isQuoteError(quote)) - expect(quote).toMatchObject({ - ...options, - maxPacketAmount: BigInt('9223372036854775807'), - sendAmount: { - value: BigInt( - Math.ceil( - Number(incomingAmount.value) / - quote.minExchangeRate.valueOf() - ) + it.each` + grantId | description + ${grantId} | ${'with a grantId'} + ${undefined} | ${'without a grantId'} + `( + 'creates a Quote $description', + async ({ grantId }): Promise => { + const scope = mockWalletQuote({ + expected + }) + const quote = await quoteService.create({ + ...options, + grantId + }) + scope.isDone() + assert.ok(!isQuoteError(quote)) + expect(quote).toMatchObject({ + ...options, + maxPacketAmount: BigInt('9223372036854775807'), + sendAmount: { + value: BigInt( + Math.ceil( + Number(incomingAmount.value) / + quote.minExchangeRate.valueOf() + ) + ), + assetCode: asset.code, + assetScale: asset.scale + }, + receiveAmount: incomingAmount, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + expiresAt: new Date( + quote.createdAt.getTime() + config.quoteLifespan ), - assetCode: asset.code, - assetScale: asset.scale - }, - receiveAmount: incomingAmount, - createdAt: expect.any(Date), - updatedAt: expect.any(Date), - expiresAt: new Date( - quote.createdAt.getTime() + config.quoteLifespan + grantId: grantId || null + }) + expect(quote.minExchangeRate.valueOf()).toBe( + 0.5 * (1 - config.slippage) ) - }) - expect(quote.minExchangeRate.valueOf()).toBe( - 0.5 * (1 - config.slippage) - ) - expect(quote.lowEstimatedExchangeRate.valueOf()).toBe(0.5) - expect(quote.highEstimatedExchangeRate.valueOf()).toBe( - 0.500000000001 - ) - await expect(quoteService.get(quote.id)).resolves.toEqual(quote) - }) + expect(quote.lowEstimatedExchangeRate.valueOf()).toBe(0.5) + expect(quote.highEstimatedExchangeRate.valueOf()).toBe( + 0.500000000001 + ) + await expect( + quoteService.get({ + id: quote.id + }) + ).resolves.toEqual(quote) + } + ) } } @@ -396,7 +447,11 @@ describe('QuoteService', (): void => { 0.500000000001 ) - await expect(quoteService.get(quote.id)).resolves.toEqual(quote) + await expect( + quoteService.get({ + id: quote.id + }) + ).resolves.toEqual(quote) }) it.each` @@ -453,7 +508,11 @@ describe('QuoteService', (): void => { 0.500000000001 ) - await expect(quoteService.get(quote.id)).resolves.toEqual(quote) + await expect( + quoteService.get({ + id: quote.id + }) + ).resolves.toEqual(quote) }) it.each` @@ -584,35 +643,4 @@ describe('QuoteService', (): void => { ).rejects.toThrow('missing prices') }) }) - - describe('getPaymentPointerPage', (): void => { - getPageTests({ - createModel: async () => - Quote.query(knex).insertAndFetch({ - paymentPointerId, - assetId, - receiver: `${ - receivingPaymentPointer.url - }/incoming-payments/${uuid()}`, - sendAmount, - receiveAmount, - maxPacketAmount: BigInt('9223372036854775807'), - lowEstimatedExchangeRate: Pay.Ratio.of( - Pay.Int.from(500000000000n) as Pay.PositiveInt, - Pay.Int.from(1000000000000n) as Pay.PositiveInt - ), - highEstimatedExchangeRate: Pay.Ratio.of( - Pay.Int.from(500000000001n) as Pay.PositiveInt, - Pay.Int.from(1000000000000n) as Pay.PositiveInt - ), - minExchangeRate: Pay.Ratio.of( - Pay.Int.from(495n) as Pay.PositiveInt, - Pay.Int.from(1000n) as Pay.PositiveInt - ), - expiresAt: new Date(Date.now() + config.quoteLifespan) - }), - getPage: (pagination: Pagination) => - quoteService.getPaymentPointerPage(paymentPointerId, pagination) - }) - }) }) diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 54f17998b6..2ab12f0faf 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -4,26 +4,27 @@ import { createHmac } from 'crypto' import { ModelObject, TransactionOrKnex } from 'objection' import * as Pay from '@interledger/pay' -import { Pagination } from '../../shared/baseModel' import { BaseService } from '../../shared/baseService' import { QuoteError, isQuoteError } from './errors' import { Quote } from './model' import { Amount, parseAmount } from '../amount' import { OpenPaymentsClientService, Receiver } from '../client/service' -import { PaymentPointer } from '../payment_pointer/model' -import { PaymentPointerService } from '../payment_pointer/service' +import { + PaymentPointer, + GetOptions, + ListOptions +} from '../payment_pointer/model' +import { + PaymentPointerService, + PaymentPointerSubresourceService +} from '../payment_pointer/service' import { RatesService } from '../../rates/service' import { IlpPlugin, IlpPluginOptions } from '../../shared/ilp_plugin' const MAX_INT64 = BigInt('9223372036854775807') -export interface QuoteService { - get(id: string): Promise +export interface QuoteService extends PaymentPointerSubresourceService { create(options: CreateQuoteOptions): Promise - getPaymentPointerPage( - paymentPointerId: string, - pagination?: Pagination - ): Promise } export interface ServiceDependencies extends BaseService { @@ -47,18 +48,17 @@ export async function createQuoteService( logger: deps_.logger.child({ service: 'QuoteService' }) } return { - get: (id) => getQuote(deps, id), + get: (options) => getQuote(deps, options), create: (options: CreateQuoteOptions) => createQuote(deps, options), - getPaymentPointerPage: (paymentPointerId, pagination) => - getPaymentPointerPage(deps, paymentPointerId, pagination) + getPaymentPointerPage: (options) => getPaymentPointerPage(deps, options) } } async function getQuote( deps: ServiceDependencies, - id: string + options: GetOptions ): Promise { - return Quote.query(deps.knex).findById(id).withGraphJoined('asset') + return Quote.query(deps.knex).get(options).withGraphFetched('asset') } export interface CreateQuoteOptions { @@ -66,6 +66,7 @@ export interface CreateQuoteOptions { sendAmount?: Amount receiveAmount?: Amount receiver: string + grantId?: string } async function createQuote( @@ -128,7 +129,8 @@ async function createQuote( lowEstimatedExchangeRate: ilpQuote.lowEstimatedExchangeRate, highEstimatedExchangeRate: ilpQuote.highEstimatedExchangeRate, // Patch using createdAt below - expiresAt: new Date(0) + expiresAt: new Date(0), + grantId: options.grantId }) .withGraphFetched('asset') @@ -344,13 +346,7 @@ export function generateQuoteSignature( async function getPaymentPointerPage( deps: ServiceDependencies, - paymentPointerId: string, - pagination?: Pagination + options: ListOptions ): Promise { - return await Quote.query(deps.knex) - .getPage(pagination) - .where({ - paymentPointerId - }) - .withGraphFetched('asset') + return await Quote.query(deps.knex).list(options).withGraphFetched('asset') } diff --git a/packages/backend/src/shared/baseModel.test.ts b/packages/backend/src/shared/baseModel.test.ts index 37e1861350..2c806ce9d6 100644 --- a/packages/backend/src/shared/baseModel.test.ts +++ b/packages/backend/src/shared/baseModel.test.ts @@ -1,8 +1,8 @@ -import { BaseModel, Pagination } from '../shared/baseModel' +import { BaseModel, Pagination } from './baseModel' interface PageTestsOptions { createModel: () => Promise - getPage: (pagination: Pagination) => Promise + getPage: (pagination?: Pagination) => Promise } export const getPageTests = ({ @@ -14,25 +14,25 @@ export const getPageTests = ({ beforeEach(async (): Promise => { modelsCreated = [] - for (let i = 0; i < 40; i++) { + for (let i = 0; i < 22; i++) { modelsCreated.push(await createModel()) } }) test.each` - pagination | expected | description - ${undefined} | ${{ length: 20, first: 0, last: 19 }} | ${'Defaults to fetching first 20 items'} - ${{ first: 10 }} | ${{ length: 10, first: 0, last: 9 }} | ${'Can change forward pagination limit'} - ${{ after: 19 }} | ${{ length: 20, first: 20, last: 39 }} | ${'Can paginate forwards from a cursor'} - ${{ first: 10, after: 9 }} | ${{ length: 10, first: 10, last: 19 }} | ${'Can paginate forwards from a cursor with a limit'} - ${{ before: 20 }} | ${{ length: 20, first: 0, last: 19 }} | ${'Can paginate backwards from a cursor'} - ${{ last: 5, before: 10 }} | ${{ length: 5, first: 5, last: 9 }} | ${'Can paginate backwards from a cursor with a limit'} - ${{ after: 19, before: 19 }} | ${{ length: 20, first: 20, last: 39 }} | ${'Providing before and after results in forward pagination'} + pagination | expected | description + ${undefined} | ${{ length: 20, first: 0, last: 19 }} | ${'Defaults to fetching first 20 items'} + ${{ first: 10 }} | ${{ length: 10, first: 0, last: 9 }} | ${'Can change forward pagination limit'} + ${{ after: 0 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Can paginate forwards from a cursor'} + ${{ first: 10, after: 9 }} | ${{ length: 10, first: 10, last: 19 }} | ${'Can paginate forwards from a cursor with a limit'} + ${{ before: 20 }} | ${{ length: 20, first: 0, last: 19 }} | ${'Can paginate backwards from a cursor'} + ${{ last: 5, before: 10 }} | ${{ length: 5, first: 5, last: 9 }} | ${'Can paginate backwards from a cursor with a limit'} + ${{ after: 0, before: 19 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Providing before and after results in forward pagination'} `('$description', async ({ pagination, expected }): Promise => { - if (pagination?.after) { + if (pagination?.after !== undefined) { pagination.after = modelsCreated[pagination.after].id } - if (pagination?.before) { + if (pagination?.before !== undefined) { pagination.before = modelsCreated[pagination.before].id } const models = await getPage(pagination) diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index e50f66e436..700acd2532 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -115,16 +115,16 @@ describe('Pagination', (): void => { if (pagination.last) pagination.before = paymentIds[cursor] else pagination.after = paymentIds[cursor] } - const page = await incomingPaymentService.getPaymentPointerPage( - defaultPaymentPointer.id, + const page = await incomingPaymentService.getPaymentPointerPage({ + paymentPointerId: defaultPaymentPointer.id, pagination - ) + }) const pageInfo = await getPageInfo( (pagination) => - incomingPaymentService.getPaymentPointerPage( - defaultPaymentPointer.id, + incomingPaymentService.getPaymentPointerPage({ + paymentPointerId: defaultPaymentPointer.id, pagination - ), + }), page ) expect(pageInfo).toEqual({ @@ -169,16 +169,16 @@ describe('Pagination', (): void => { if (pagination.last) pagination.before = paymentIds[cursor] else pagination.after = paymentIds[cursor] } - const page = await outgoingPaymentService.getPaymentPointerPage( - defaultPaymentPointer.id, + const page = await outgoingPaymentService.getPaymentPointerPage({ + paymentPointerId: defaultPaymentPointer.id, pagination - ) + }) const pageInfo = await getPageInfo( (pagination) => - outgoingPaymentService.getPaymentPointerPage( - defaultPaymentPointer.id, + outgoingPaymentService.getPaymentPointerPage({ + paymentPointerId: defaultPaymentPointer.id, pagination - ), + }), page ) expect(pageInfo).toEqual({ @@ -223,16 +223,16 @@ describe('Pagination', (): void => { if (pagination.last) pagination.before = quoteIds[cursor] else pagination.after = quoteIds[cursor] } - const page = await quoteService.getPaymentPointerPage( - defaultPaymentPointer.id, + const page = await quoteService.getPaymentPointerPage({ + paymentPointerId: defaultPaymentPointer.id, pagination - ) + }) const pageInfo = await getPageInfo( (pagination) => - quoteService.getPaymentPointerPage( - defaultPaymentPointer.id, + quoteService.getPaymentPointerPage({ + paymentPointerId: defaultPaymentPointer.id, pagination - ), + }), page ) expect(pageInfo).toEqual({ diff --git a/packages/backend/src/shared/routes.test.ts b/packages/backend/src/shared/routes.test.ts deleted file mode 100644 index b5fff39d85..0000000000 --- a/packages/backend/src/shared/routes.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as httpMocks from 'node-mocks-http' -import { PaymentPointerContext, ListContext } from '../app' -import { PaymentPointer } from '../open_payments/payment_pointer/model' -import { Grant } from '../open_payments/auth/grant' -import { createContext } from '../tests/context' - -interface BaseResponse { - id: string -} - -interface SetupOptions { - reqOpts: httpMocks.RequestOptions - params?: Record - paymentPointer: PaymentPointer - grant?: Grant -} - -export const setup = ( - options: SetupOptions -): T => { - const ctx = createContext( - { - ...options.reqOpts, - headers: Object.assign( - { Accept: 'application/json', 'Content-Type': 'application/json' }, - options.reqOpts.headers - ) - }, - options.params - ) - if (options.reqOpts.body !== undefined) { - ctx.request.body = options.reqOpts.body - } - ctx.paymentPointer = options.paymentPointer - if (options.grant) ctx.grant = options.grant - return ctx -} - -interface ListTestsOptions { - getPaymentPointer: () => PaymentPointer - getGrant: () => Grant | undefined - getUrl: () => string - createItem: (index: number) => Promise - list: (ctx: ListContext) => Promise -} - -export const listTests = ({ - getPaymentPointer, - getGrant, - getUrl, - createItem, - list -}: ListTestsOptions): void => { - describe('Common list route pagination', (): void => { - let items: Type[] - - const getCursor = (index: number) => items[index].id.split('/').pop() - - beforeEach(async (): Promise => { - items = [] - for (let i = 0; i < 3; i++) { - items.push(await createItem(i)) - } - }) - - test.each` - query | cursorIndex | pagination | startIndex | endIndex | description - ${{}} | ${-1} | ${{ hasPreviousPage: false, hasNextPage: false }} | ${0} | ${2} | ${'no pagination parameters'} - ${{ first: 10 }} | ${-1} | ${{ hasPreviousPage: false, hasNextPage: false }} | ${0} | ${2} | ${'only `first`'} - ${{ first: 10 }} | ${0} | ${{ hasPreviousPage: true, hasNextPage: false }} | ${1} | ${2} | ${'`first` plus `cursor`'} - ${{ last: 10 }} | ${2} | ${{ hasPreviousPage: false, hasNextPage: true }} | ${0} | ${1} | ${'`last` plus `cursor`'} - `( - 'returns 200 on $description', - async ({ - query, - cursorIndex, - pagination, - startIndex, - endIndex - }): Promise => { - const cursor = items[cursorIndex] ? getCursor(cursorIndex) : undefined - if (cursor) { - query['cursor'] = cursor - } - pagination['startCursor'] = getCursor(startIndex) - pagination['endCursor'] = getCursor(endIndex) - const ctx = setup({ - reqOpts: { - headers: { Accept: 'application/json' }, - method: 'GET', - query, - url: getUrl() - }, - paymentPointer: getPaymentPointer(), - grant: getGrant() - }) - await expect(list(ctx)).resolves.toBeUndefined() - expect(ctx.response).toSatisfyApiSpec() - expect(ctx.body).toEqual({ - pagination, - result: items.slice(startIndex, endIndex + 1) - }) - } - ) - }) -} - -test.todo('test suite must contain at least one test') diff --git a/packages/backend/src/spsp/routes.test.ts b/packages/backend/src/spsp/routes.test.ts index c4ba174fd9..cb1a170265 100644 --- a/packages/backend/src/spsp/routes.test.ts +++ b/packages/backend/src/spsp/routes.test.ts @@ -3,11 +3,11 @@ import { Knex } from 'knex' import { AppServices } from '../app' import { SPSPRoutes } from './routes' -import { setup } from '../shared/routes.test' import { createTestApp, TestContainer } from '../tests/app' import { initIocContainer } from '../' import { Config } from '../config/app' import { PaymentPointer } from '../open_payments/payment_pointer/model' +import { setup } from '../open_payments/payment_pointer/model.test' import { IocContract } from '@adonisjs/fold' import { StreamServer } from '@interledger/stream-receiver' diff --git a/packages/backend/src/tests/grant.ts b/packages/backend/src/tests/grant.ts new file mode 100644 index 0000000000..6133534ba3 --- /dev/null +++ b/packages/backend/src/tests/grant.ts @@ -0,0 +1,24 @@ +import { IocContract } from '@adonisjs/fold' +import { v4 as uuid } from 'uuid' + +import { AppServices } from '../app' +import { Grant, GrantOptions } from '../open_payments/auth/grant' + +export async function createGrant( + deps: IocContract, + { grant: id, clientId, active = true, access = [] }: Partial +): Promise { + const grant = new Grant({ + grant: id || uuid(), + clientId: clientId || uuid(), + active, + access + }) + const grantReferenceService = await deps.use('grantReferenceService') + await grantReferenceService.create({ + id: grant.grant, + clientId: grant.clientId + }) + + return grant +} diff --git a/packages/backend/src/tests/quote.ts b/packages/backend/src/tests/quote.ts index b2eace3bbb..a2addd1971 100644 --- a/packages/backend/src/tests/quote.ts +++ b/packages/backend/src/tests/quote.ts @@ -19,6 +19,7 @@ export async function createQuote( receiver: receiverUrl, sendAmount, receiveAmount, + grantId, validDestination = true }: CreateTestQuoteOptions ): Promise { @@ -104,7 +105,8 @@ export async function createQuote( Pay.Int.from(495n) as Pay.PositiveInt, Pay.Int.from(1000n) as Pay.PositiveInt ), - expiresAt: new Date(Date.now() + config.quoteLifespan) + expiresAt: new Date(Date.now() + config.quoteLifespan), + grantId }) .withGraphFetched('asset') }