diff --git a/docs/webhooks.md b/docs/webhooks.md index 7710b14..e928573 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -87,7 +87,7 @@ All management requests require the following headers for wallet verification: ## 2. Webhook Event Payload -When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON body to the registered `callback_url`. +When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON body to the registered `callback_url`. There are two supported event types — `buy` and `sell` — and both use the same payload shape, distinguished only by the `event_type` field value. ### Payload Fields @@ -101,7 +101,12 @@ When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON | `fee_paid` | `string` | The protocol/creator fee paid for this trade in XLM. | `"0.4650000"` | | `timestamp` | `string` | The ISO 8601 UTC timestamp of the trade event transaction. | `"2026-06-23T04:00:00.000Z"` | -### Example Payload +> [!NOTE] +> **Nullable/Optional Fields:** None of the fields above are ever `null` or absent. Every field is populated on every dispatch for both event types below — there is no partial or conditional payload shape to account for. + +### `buy` Event + +Fired when a trader purchases a creator's keys. `buyer_or_seller_address` holds the **buyer's** wallet address. ```json { @@ -115,6 +120,22 @@ When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON } ``` +### `sell` Event + +Fired when a trader sells a creator's keys. `buyer_or_seller_address` holds the **seller's** wallet address. + +```json +{ + "event_type": "sell", + "creator_id": "GCSW65D4G56DF8B2N7M9L3K4J2XDF", + "buyer_or_seller_address": "GDD3DDK4J5H5D9S8A7P6O5I4U8LKF", + "amount": "50.0000000", + "price": "12.2500000", + "fee_paid": "0.3062500", + "timestamp": "2026-06-23T05:15:00.000Z" +} +``` + --- ## 3. Delivery Guarantees & Ordering diff --git a/src/modules/alerts/__tests__/alert-lifecycle-threshold-crossing.integration.test.ts b/src/modules/alerts/__tests__/alert-lifecycle-threshold-crossing.integration.test.ts new file mode 100644 index 0000000..dd05d5c --- /dev/null +++ b/src/modules/alerts/__tests__/alert-lifecycle-threshold-crossing.integration.test.ts @@ -0,0 +1,175 @@ +// Integration test for the full price alert lifecycle: registering an alert, +// evaluating a price snapshot update that crosses the threshold, and +// confirming the alert is not re-queued on a subsequent snapshot update. + +import { createAlert, evaluatePriceAlertsForMovement } from '../alert.service'; +import { prisma } from '../../../utils/prisma.utils'; + +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + priceAlert: { + findFirst: jest.fn(), + create: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }, +})); + +const mockPrisma = prisma as unknown as { + priceAlert: { + findFirst: jest.Mock; + create: jest.Mock; + findMany: jest.Mock; + update: jest.Mock; + }; +}; + +const CREATOR_ID = 'creator-threshold-test'; +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const CALLBACK_URL = 'https://hooks.example.com/price-alert'; +const TARGET_PRICE = 500; + +describe('price alert lifecycle: create -> threshold crossing -> delivery', () => { + let createdAlert: { + id: string; + creatorId: string; + walletAddress: string; + targetPrice: number; + direction: 'above' | 'below'; + callbackUrl: string; + isActive: boolean; + triggeredAt: Date | null; + createdAt: Date; + }; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn().mockResolvedValue({ ok: true }); + + createdAlert = { + id: 'alert-threshold-crossing', + creatorId: CREATOR_ID, + walletAddress: WALLET_ADDRESS, + targetPrice: TARGET_PRICE, + direction: 'above', + callbackUrl: CALLBACK_URL, + isActive: true, + triggeredAt: null, + createdAt: new Date('2026-07-01T00:00:00Z'), + }; + + mockPrisma.priceAlert.findFirst.mockResolvedValue(null); + mockPrisma.priceAlert.create.mockResolvedValue(createdAlert); + }); + + it('creates an alert at threshold 500, dispatches delivery when price moves from 400 to 600, and marks it triggered', async () => { + const alert = await createAlert({ + creator_id: CREATOR_ID, + wallet_address: WALLET_ADDRESS, + target_price: TARGET_PRICE, + direction: 'above', + callback_url: CALLBACK_URL, + }); + + expect(mockPrisma.priceAlert.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + creatorId: CREATOR_ID, + targetPrice: TARGET_PRICE, + direction: 'above', + }), + }) + ); + + // The snapshot update crosses the threshold (400 -> 600). + mockPrisma.priceAlert.findMany.mockResolvedValueOnce([createdAlert]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 400, + currentPrice: 600, + }); + + // Delivery job (webhook dispatch) was attempted for the crossing. + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + CALLBACK_URL, + expect.objectContaining({ method: 'POST' }) + ); + const body = JSON.parse( + (global.fetch as jest.Mock).mock.calls[0][1].body + ); + expect(body.alert_id).toBe(alert.id); + expect(body.target_price).toBe(TARGET_PRICE); + expect(body.current_price).toBe(600); + + // Alert is marked triggered. + expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith({ + where: { id: createdAlert.id }, + data: expect.objectContaining({ + isActive: false, + triggeredAt: expect.any(Date), + }), + }); + }); + + it('does not re-queue delivery on the next snapshot update after the alert has triggered', async () => { + await createAlert({ + creator_id: CREATOR_ID, + wallet_address: WALLET_ADDRESS, + target_price: TARGET_PRICE, + direction: 'above', + callback_url: CALLBACK_URL, + }); + + // First crossing: 400 -> 600 triggers delivery and marks the alert inactive. + mockPrisma.priceAlert.findMany.mockResolvedValueOnce([createdAlert]); + mockPrisma.priceAlert.update.mockResolvedValue({}); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 400, + currentPrice: 600, + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Next snapshot update: the alert is no longer active/untriggered, so the + // query that scopes to isActive+untriggered alerts returns nothing. + mockPrisma.priceAlert.findMany.mockResolvedValueOnce([]); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 600, + currentPrice: 700, + }); + + // No additional delivery or update calls. + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockPrisma.priceAlert.update).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch delivery when the price moves but does not cross the threshold', async () => { + await createAlert({ + creator_id: CREATOR_ID, + wallet_address: WALLET_ADDRESS, + target_price: TARGET_PRICE, + direction: 'above', + callback_url: CALLBACK_URL, + }); + + mockPrisma.priceAlert.findMany.mockResolvedValueOnce([createdAlert]); + + await evaluatePriceAlertsForMovement({ + creatorId: CREATOR_ID, + previousPrice: 400, + currentPrice: 450, + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/wallets/wallet-activity.service.integration.test.ts b/src/modules/wallets/wallet-activity.service.integration.test.ts index 0b01699..66411a4 100644 --- a/src/modules/wallets/wallet-activity.service.integration.test.ts +++ b/src/modules/wallets/wallet-activity.service.integration.test.ts @@ -156,4 +156,26 @@ describe('fetchWalletActivity type filter integration', () => { }) ); }); + + it('returns an empty array when there are no activities, regardless of filter', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + + const [buyItems, buyTotal] = await fetchWalletActivity(WALLET_ADDRESS, { + type: 'buy', + limit: 20, + offset: 0, + }); + + expect(buyItems).toEqual([]); + expect(buyTotal).toBe(0); + + const [allItems, allTotal] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + expect(allItems).toEqual([]); + expect(allTotal).toBe(0); + }); }); diff --git a/src/utils/pagination.utils.ts b/src/utils/pagination.utils.ts index 1c964b1..f959c8e 100644 --- a/src/utils/pagination.utils.ts +++ b/src/utils/pagination.utils.ts @@ -111,3 +111,40 @@ export async function paginateQuery( return { data, nextCursor, hasMore }; } + +export type PaginatedResponse = { + items: T[]; + has_more: boolean; + next_cursor: string | null; +}; + +/** + * Builds a paginated response envelope from an over-fetched result set. + * + * Callers fetch up to `limit + 1` items; if the extra item is present, it is + * popped off and used to derive `next_cursor` for the next page. + * + * @param items Result set, expected to contain up to `limit + 1` items + * @param limit The page size requested by the caller + * @param cursorFn Extracts the cursor value from the last item kept on the page + */ +export function buildPaginatedResponse( + items: T[], + limit: number, + cursorFn: (item: T) => string +): PaginatedResponse { + if (items.length > limit) { + const page = items.slice(0, limit); + return { + items: page, + has_more: true, + next_cursor: cursorFn(page[page.length - 1]), + }; + } + + return { + items, + has_more: false, + next_cursor: null, + }; +} diff --git a/src/utils/test/pagination.utils.test.ts b/src/utils/test/pagination.utils.test.ts index 5dd1fc0..cb23fe6 100644 --- a/src/utils/test/pagination.utils.test.ts +++ b/src/utils/test/pagination.utils.test.ts @@ -1,4 +1,4 @@ -import { paginateQuery } from '../pagination.utils'; +import { paginateQuery, buildPaginatedResponse } from '../pagination.utils'; describe('paginateQuery', () => { const mockData = [ @@ -62,3 +62,44 @@ describe('paginateQuery', () => { expect(result.nextCursor).toBeUndefined(); }); }); + +describe('buildPaginatedResponse', () => { + const items = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ]; + const cursorFn = (item: { id: number }) => String(item.id); + + it('returns has_more: false and next_cursor: null when items are under the limit', () => { + const result = buildPaginatedResponse(items.slice(0, 2), 5, cursorFn); + + expect(result.items).toEqual(items.slice(0, 2)); + expect(result.has_more).toBe(false); + expect(result.next_cursor).toBeNull(); + }); + + it('returns has_more: false and next_cursor: null when items exactly match the limit', () => { + const result = buildPaginatedResponse(items, 3, cursorFn); + + expect(result.items).toEqual(items); + expect(result.has_more).toBe(false); + expect(result.next_cursor).toBeNull(); + }); + + it('pops the extra item and sets next_cursor when items exceed the limit', () => { + const result = buildPaginatedResponse(items, 2, cursorFn); + + expect(result.items).toEqual(items.slice(0, 2)); + expect(result.has_more).toBe(true); + expect(result.next_cursor).toBe('2'); + }); + + it('returns an empty items array with has_more: false for an empty result set', () => { + const result = buildPaginatedResponse([], 5, cursorFn); + + expect(result.items).toEqual([]); + expect(result.has_more).toBe(false); + expect(result.next_cursor).toBeNull(); + }); +});