From 13d92e44df757a951346d6881c0486dc54210cf2 Mon Sep 17 00:00:00 2001 From: AdaBebe0 Date: Tue, 2 Jun 2026 01:52:27 +0000 Subject: [PATCH] chore: add E2E Docker Compose test, aggregator property tests, and OpenAPI publish workflow --- .github/workflows/openapi-publish.yml | 54 ++ Dockerfile | 6 +- README.md | 2 +- docker-compose.yml | 3 +- openapi.json | 798 ++++++++++++++++++++++++++ package-lock.json | 91 ++- package.json | 3 + scripts/generate-openapi.ts | 9 + src/alerts/threshold.ts | 16 +- src/index.ts | 4 + tests/aggregator.property.test.ts | 110 ++++ tests/integration/e2e.test.ts | 85 +++ vitest.config.ts | 2 +- 13 files changed, 1161 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/openapi-publish.yml create mode 100644 openapi.json create mode 100644 scripts/generate-openapi.ts create mode 100644 tests/aggregator.property.test.ts create mode 100644 tests/integration/e2e.test.ts diff --git a/.github/workflows/openapi-publish.yml b/.github/workflows/openapi-publish.yml new file mode 100644 index 00000000..9a6a68ed --- /dev/null +++ b/.github/workflows/openapi-publish.yml @@ -0,0 +1,54 @@ +name: OpenAPI publish + +on: + push: + branches: [main] + +jobs: + publish: + name: Generate and publish OpenAPI + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run openapi:gen + - run: | + mkdir -p ./openapi-site + cp openapi.json ./openapi-site/openapi.json + cat > ./openapi-site/index.html <<'EOF' + + + + + + Lens OpenAPI + + + +
+ + + + + EOF + - uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./openapi-site + publish_branch: gh-pages + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' diff --git a/Dockerfile b/Dockerfile index dc1eef40..4f6b66d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build -FROM node:20-alpine AS builder +FROM node:20-slim AS builder WORKDIR /app @@ -13,12 +13,12 @@ COPY . . RUN npm run build # Stage 2: Run -FROM node:20-alpine +FROM node:20-slim WORKDIR /app # Install curl for healthcheck -RUN apk add --no-cache curl +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* # Copy production dependencies COPY package*.json ./ diff --git a/README.md b/README.md index 051fe8f4..2d6e7038 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). -The API specification is available in [OpenAPI 3.0 format](openapi.yaml). +The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json. ## Examples diff --git a/docker-compose.yml b/docker-compose.yml index b62444d3..f81b5123 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: lens: build: . @@ -9,6 +7,7 @@ services: - DATABASE_URL=postgresql://lens:lens@postgres:5432/lens - REDIS_URL=redis://redis:6379 - WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + - REQUIRE_API_KEY=false depends_on: postgres: condition: service_healthy diff --git a/openapi.json b/openapi.json new file mode 100644 index 00000000..fcf9018d --- /dev/null +++ b/openapi.json @@ -0,0 +1,798 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Lens API", + "description": "Unified Stellar price API aggregating SDEX + AMM prices.", + "version": "0.1.0", + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + } + }, + "servers": [ + { + "url": "http://localhost:4000", + "description": "Local development server" + } + ], + "paths": { + "/status": { + "get": { + "operationId": "getStatus", + "summary": "Get API status and indexer state", + "security": [], + "responses": { + "200": { + "description": "API status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "watchedPairs": { + "type": "array", + "items": { + "type": "string" + } + }, + "lastIndexedLedger": { + "type": "integer", + "nullable": true + }, + "lastProcessedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/price/{assetA}/{assetB}": { + "get": { + "operationId": "getPrice", + "summary": "Get aggregated price for a pair", + "security": [ + { + "x402": [] + } + ], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "XLM" + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "USDC" + } + ], + "responses": { + "200": { + "description": "Aggregated price data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AggregatedPrice" + } + } + } + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "404": { + "description": "Pair not watched" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/price/{assetA}/{assetB}/route": { + "get": { + "operationId": "getBestRoute", + "summary": "Get best swap route for a pair and amount", + "security": [ + { + "x402": [] + } + ], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "amount", + "in": "query", + "required": false, + "schema": { + "type": "number", + "default": 1000 + } + } + ], + "responses": { + "200": { + "description": "Best route information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouteInfo" + } + } + } + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/price/{assetA}/{assetB}/history": { + "get": { + "operationId": "getPriceHistory", + "summary": "Get price history buckets", + "security": [], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1m", + "5m", + "1h", + "24h" + ], + "default": "1h" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 100, + "maximum": 1000 + } + } + ], + "responses": { + "200": { + "description": "Price history", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pairKey": { + "type": "string" + }, + "window": { + "type": "string" + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PriceBucket" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/pools": { + "get": { + "operationId": "getPools", + "summary": "Get AMM liquidity pool reserves and spot prices", + "security": [ + { + "x402": [] + } + ], + "responses": { + "200": { + "description": "List of pools", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pools": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pool_id": { + "type": "string" + }, + "asset_a": { + "type": "string" + }, + "asset_b": { + "type": "string" + }, + "reserve_a": { + "type": "number" + }, + "reserve_b": { + "type": "number" + }, + "spot_price": { + "type": "number" + }, + "fee_bp": { + "type": "integer" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/candles/{assetA}/{assetB}": { + "get": { + "operationId": "getCandles", + "summary": "Get OHLCV candle data", + "security": [ + { + "x402": [] + } + ], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "interval", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1m", + "5m", + "15m", + "1h", + "4h", + "1d" + ], + "default": "1h" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "Candle data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pairKey": { + "type": "string" + }, + "interval": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "candles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time" + }, + "open": { + "type": "number" + }, + "high": { + "type": "number" + }, + "low": { + "type": "number" + }, + "close": { + "type": "number" + }, + "volume": { + "type": "number" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters" + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/pairs": { + "get": { + "operationId": "listPairs", + "summary": "List all active trading pairs", + "security": [], + "responses": { + "200": { + "description": "List of pairs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pairs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pairKey": { + "type": "string" + }, + "assetA": { + "$ref": "#/components/schemas/Asset" + }, + "assetB": { + "$ref": "#/components/schemas/Asset" + }, + "latestPrice": { + "type": "number", + "nullable": true + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + }, + "post": { + "operationId": "addPair", + "summary": "Add a new trading pair (Admin only)", + "security": [ + { + "adminAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "assetA": { + "type": "string", + "example": "XLM" + }, + "assetB": { + "type": "string", + "example": "USDC:GBBD47..." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Pair added" + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Unauthorized" + }, + "409": { + "description": "Pair already exists" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + } + }, + "components": { + "securitySchemes": { + "x402": { + "type": "apiKey", + "name": "X-PAYMENT", + "in": "header", + "description": "Base64 encoded JSON payment proof for x402 micropayments." + }, + "adminAuth": { + "type": "apiKey", + "name": "X-Admin-Key", + "in": "header" + } + }, + "responses": { + "PaymentRequired": { + "description": "Payment Required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "x402Version": { + "type": "integer" + }, + "accepts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scheme": { + "type": "string" + }, + "price": { + "type": "string" + }, + "network": { + "type": "string" + }, + "payTo": { + "type": "string" + } + } + } + }, + "error": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "TooManyRequests": { + "description": "Rate limit exceeded", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds to wait before retrying" + }, + "X-RateLimit-Remaining": { + "schema": { + "type": "integer" + }, + "description": "Remaining requests in current window" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "statusCode": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "retryAfter": { + "type": "string" + } + } + } + } + } + } + }, + "schemas": { + "Asset": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "issuer": { + "type": "string", + "nullable": true + } + } + }, + "AggregatedPrice": { + "type": "object", + "properties": { + "assetA": { + "type": "string" + }, + "assetB": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "price": { + "type": "number" + }, + "sdexPrice": { + "type": "number" + }, + "ammPrice": { + "type": "number" + }, + "bestRoute": { + "type": "string", + "enum": [ + "SDEX", + "AMM", + "SPLIT", + "UNKNOWN" + ] + }, + "volume24h": { + "type": "number" + }, + "sdexVolume24h": { + "type": "number" + }, + "ammVolume24h": { + "type": "number" + }, + "vwap1m": { + "type": "number" + }, + "vwap5m": { + "type": "number" + }, + "vwap1h": { + "type": "number" + }, + "vwap24h": { + "type": "number" + }, + "priceChange24h": { + "type": "number" + }, + "lastUpdated": { + "type": "string", + "format": "date-time" + }, + "sources": { + "type": "integer" + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "unknown" + ] + }, + "lastTradeAgeSeconds": { + "type": "integer", + "nullable": true + } + } + }, + "RouteInfo": { + "type": "object", + "properties": { + "route": { + "type": "string", + "enum": [ + "SDEX", + "AMM", + "SPLIT", + "UNKNOWN" + ] + }, + "sdexPrice": { + "type": "number" + }, + "ammPrice": { + "type": "number" + }, + "estimatedOutput": { + "type": "number" + }, + "slippagePct": { + "type": "number" + }, + "recommendation": { + "type": "string" + } + } + }, + "PriceBucket": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "format": "date-time" + }, + "window": { + "type": "string" + }, + "vwap": { + "type": "number" + }, + "sdexVwap": { + "type": "number", + "nullable": true + }, + "ammVwap": { + "type": "number", + "nullable": true + }, + "volume": { + "type": "number" + }, + "tradeCount": { + "type": "integer" + }, + "open": { + "type": "number", + "nullable": true + }, + "close": { + "type": "number", + "nullable": true + }, + "high": { + "type": "number", + "nullable": true + }, + "low": { + "type": "number", + "nullable": true + } + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 418b6e68..c6e3b6ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,8 @@ "@types/pg": "^8.0.0", "@types/uuid": "^9.0.0", "@vitest/coverage-v8": "^4.1.5", + "fast-check": "^3.0.0", + "js-yaml": "^4.2.0", "prisma": "^5.0.0", "tsx": "^4.0.0", "typescript": "^5.0.0", @@ -352,6 +354,29 @@ "prettier": "^2.7.1" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2013,7 +2038,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2043,7 +2067,6 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -3149,6 +3172,29 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -3482,7 +3528,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3636,7 +3681,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -4012,10 +4056,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4949,7 +5003,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -5047,7 +5100,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5202,7 +5254,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5290,6 +5341,23 @@ "pump": "^3.0.0" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qlobber": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", @@ -6101,7 +6169,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6198,7 +6265,6 @@ "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6277,7 +6343,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", diff --git a/package.json b/package.json index fe987eac..ea78527a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "node node_modules/prisma/build/index.js generate && tsc", "start": "node dist/index.js", "test": "vitest run", + "openapi:gen": "tsx scripts/generate-openapi.ts", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset tag", @@ -43,6 +44,8 @@ "@types/pg": "^8.0.0", "@types/uuid": "^9.0.0", "@vitest/coverage-v8": "^4.1.5", + "fast-check": "^3.0.0", + "js-yaml": "^4.2.0", "prisma": "^5.0.0", "tsx": "^4.0.0", "typescript": "^5.0.0", diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 00000000..49d42948 --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,9 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import YAML from 'js-yaml' + +const sourcePath = new URL('../openapi.yaml', import.meta.url) +const destinationPath = new URL('../openapi.json', import.meta.url) +const contents = readFileSync(sourcePath, 'utf8') +const document = YAML.load(contents) +writeFileSync(destinationPath, JSON.stringify(document, null, 2) + '\n') +console.log('Generated openapi.json') diff --git a/src/alerts/threshold.ts b/src/alerts/threshold.ts index fa159274..882aa9fd 100644 --- a/src/alerts/threshold.ts +++ b/src/alerts/threshold.ts @@ -19,11 +19,19 @@ export interface ThresholdAlertPayload { timestamp: string } +function isThresholdDirection(value: string): value is ThresholdDirection { + return value === 'above' || value === 'below' +} + export function crossesThreshold( - subscription: Pick, + subscription: Pick & { direction: string }, previousPrice: number, currentPrice: number, ): boolean { + if (!isThresholdDirection(subscription.direction)) { + throw new Error(`Unsupported threshold direction: ${subscription.direction}`) + } + if (subscription.direction === 'above') { return previousPrice < subscription.threshold && currentPrice >= subscription.threshold } @@ -32,12 +40,16 @@ export function crossesThreshold( } export function buildThresholdAlertPayload( - subscription: Pick, + subscription: Pick & { direction: string }, assetA: string, assetB: string, price: number, timestamp = new Date().toISOString(), ): ThresholdAlertPayload { + if (!isThresholdDirection(subscription.direction)) { + throw new Error(`Unsupported threshold direction: ${subscription.direction}`) + } + return { assetA, assetB, diff --git a/src/index.ts b/src/index.ts index 17c5bf4c..cb8d58b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,10 @@ import 'dotenv/config' import { execSync } from 'child_process' import Fastify from 'fastify' + +if (!process.env.DIRECT_DATABASE_URL && process.env.DATABASE_URL) { + process.env.DIRECT_DATABASE_URL = process.env.DATABASE_URL +} import cors from '@fastify/cors' import compress from '@fastify/compress' import rateLimit from '@fastify/rate-limit' diff --git a/tests/aggregator.property.test.ts b/tests/aggregator.property.test.ts new file mode 100644 index 00000000..a842e619 --- /dev/null +++ b/tests/aggregator.property.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import fc from 'fast-check' +import { getBestRoute } from '../src/aggregator/bestRoute' +import { pgPool } from '../src/db' +import * as StellarSdk from '@stellar/stellar-sdk' + +vi.mock('../src/db', () => ({ + pgPool: { + query: vi.fn(), + }, +})) + +vi.mock('@stellar/stellar-sdk', () => { + const callFn = vi.fn() + return { + Horizon: { + Server: vi.fn(function () { + return { + strictSendPaths: vi.fn().mockReturnThis(), + call: callFn, + } + }), + }, + Asset: Object.assign( + vi.fn(function (code: string, issuer: string | null) { + return { code, issuer } + }), + { native: vi.fn(() => 'native') } + ), + __mockCall: callFn, + } +}) + +describe('Price aggregator property tests', () => { + const assetA = { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' } + const assetB = { code: 'XLM', issuer: null } + const pairKey = 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM' + const mockQuery = vi.mocked(pgPool.query) + const mockCall = (StellarSdk as any).__mockCall as ReturnType + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('produces valid route results for random venue prices', async () => { + await fc.assert( + fc.asyncProperty( + fc.float({ min: 0, max: 2000, noNaN: true, noDefaultInfinity: true, noNegativeZero: true }), + fc.float({ min: 0, max: 2000, noNaN: true, noDefaultInfinity: true, noNegativeZero: true }), + fc.integer({ min: 1, max: 20000 }), + async (sdexPrice, ammPrice, amount) => { + const feeBp = 30 + const fee = 1 - feeBp / 10000 + + if (sdexPrice <= 0) { + mockCall.mockResolvedValue({ records: [] }) + } else { + mockCall.mockResolvedValue({ records: [{ destination_amount: String(sdexPrice * amount) }] }) + } + + if (ammPrice <= 0) { + mockQuery.mockResolvedValue({ rows: [] } as any) + } else { + const reserveA = 10000 + const reserveB = String((ammPrice * (reserveA + amount * fee)) / fee) + mockQuery.mockResolvedValue({ rows: [{ reserve_a: String(reserveA), reserve_b: reserveB, fee_bp: String(feeBp) }] } as any) + } + + if (sdexPrice === 0 && ammPrice === 0) { + await expect(getBestRoute(assetA, assetB, pairKey, amount)).rejects.toThrow('No pricing data available') + return + } + + const result = await getBestRoute(assetA, assetB, pairKey, amount) + expect(result.sdexPrice).toBeGreaterThanOrEqual(0) + expect(result.ammPrice).toBeGreaterThanOrEqual(0) + expect(['SDEX', 'AMM', 'SPLIT', 'UNKNOWN']).toContain(result.route) + expect(result.estimatedOutput).toBeGreaterThanOrEqual(0) + expect(result.slippagePct).toBeGreaterThanOrEqual(0) + expect(result.slippagePct).toBeCloseTo(0, 6) + + if (sdexPrice === 0) { + expect(result.route).toBe('AMM') + expect(result.estimatedOutput).toBeCloseTo(ammPrice * amount, 6) + } + if (ammPrice === 0) { + expect(result.route).toBe('SDEX') + expect(result.estimatedOutput).toBeCloseTo(sdexPrice * amount, 6) + } + if (sdexPrice > 0 && ammPrice > 0) { + const diff = Math.abs(sdexPrice - ammPrice) / Math.max(sdexPrice, ammPrice) + if (diff < 0.001) { + if (amount > 10000) { + expect(result.route).toBe('SPLIT') + } else { + expect(['SDEX', 'AMM']).toContain(result.route) + } + } else if (sdexPrice > ammPrice) { + expect(result.route).toBe('SDEX') + } else { + expect(result.route).toBe('AMM') + } + expect(result.estimatedOutput).toBeCloseTo(Math.max(sdexPrice, ammPrice) * amount, 6) + } + } + ), + { numRuns: 10000 } + ) + }) +}) diff --git a/tests/integration/e2e.test.ts b/tests/integration/e2e.test.ts new file mode 100644 index 00000000..d277d470 --- /dev/null +++ b/tests/integration/e2e.test.ts @@ -0,0 +1,85 @@ +import { beforeAll, afterAll, describe, it, expect } from 'vitest' +import { execSync } from 'node:child_process' + +const COMPOSE_FILE = 'docker-compose.yml' +const API_URL = 'http://127.0.0.1:3002' +const PAIR_KEY = 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM' + +function run(command: string) { + execSync(command, { stdio: 'inherit' }) +} + +function runCapture(command: string) { + return execSync(command, { encoding: 'utf8' }).trim() +} + +async function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function waitForDbRows(pairKey: string, poolId: string, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const priceCount = parseInt(runCapture(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -t -A -c "SELECT count(*) FROM price_points WHERE pair_key = '${pairKey}' AND source = 'AMM' AND pool_id = '${poolId}'"`), 10) + const poolCount = parseInt(runCapture(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -t -A -c "SELECT count(*) FROM pool_snapshots WHERE pool_id = '${poolId}' AND id = 'test-snapshot-1'"`), 10) + if (priceCount >= 1 && poolCount >= 1) return + } catch { + // retry until timeout + } + await sleep(1000) + } + throw new Error('Test rows were not visible in the database before querying Lens') +} + +function clearPriceCache(pairKey: string) { + run(`docker compose -f ${COMPOSE_FILE} exec -T redis redis-cli DEL "lens:price:${pairKey}"`) +} + +async function waitForService(timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const response = await fetch(`${API_URL}/status`) + if (response.ok) return + } catch { + // retry + } + await sleep(2000) + } + throw new Error('Lens service did not become healthy in time') +} + +describe('Docker Compose end-to-end ingest → query', () => { + beforeAll(async () => { + run(`docker compose -f ${COMPOSE_FILE} down --volumes --remove-orphans`) + run(`docker compose -f ${COMPOSE_FILE} up -d --build`) + await waitForService() + + run(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -c "INSERT INTO price_points (id, asset_a, asset_b, pair_key, source, pool_id, price, base_volume, counter_volume, ledger, timestamp) VALUES ('test-price-point-1', 'XLM', 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', '${PAIR_KEY}', 'AMM', 'test-pool-1', '1.2345', '1000', '1234.5', 12345, now())"`) + run(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -c "INSERT INTO pool_snapshots (id, pool_id, asset_a, asset_b, reserve_a, reserve_b, spot_price, total_shares, fee_bp, ledger, timestamp) VALUES ('test-snapshot-1', 'test-pool-1', 'XLM', 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', '10000', '12345', '1.2345', '100000', 30, 12345, now())"`) + await waitForDbRows(PAIR_KEY, 'test-pool-1') + clearPriceCache(PAIR_KEY) + await sleep(2000) + }, 180000) + + afterAll(() => { + run(`docker compose -f ${COMPOSE_FILE} down --volumes --remove-orphans`) + }) + + it('starts Lens, ingests test market data, and returns query results', async () => { + const response = await fetch(`${API_URL}/price/XLM/USDC`) + const body = await response.json() + console.log('E2E RESPONSE', { status: response.status, body }) + + expect(response.ok).toBe(true) + expect(body).toHaveProperty('assetA', 'XLM') + expect(body).toHaveProperty('assetB', 'USDC') + expect(body).toHaveProperty('pairKey', expect.stringContaining('USDC')) + expect(body).toHaveProperty('price') + expect(body.price).toBeGreaterThan(0) + expect(body).toHaveProperty('ammPrice') + expect(body.ammPrice).toBeGreaterThan(0) + expect(['SDEX', 'AMM', 'SPLIT', 'UNKNOWN']).toContain(body.bestRoute) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index c474d106..1761797c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { environment: 'node', globals: true, - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], exclude: ['dist/**', 'node_modules/**'], }, }) \ No newline at end of file