Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ on:
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: accesslayer
POSTGRES_PASSWORD: local-test-only
POSTGRES_DB: accesslayer_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U accesslayer -d accesslayer_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://accesslayer:local-test-only@localhost:5432/accesslayer_test
REDIS_URL: redis://localhost:6379
MODE: test

steps:
- name: Checkout
Expand Down Expand Up @@ -40,6 +67,12 @@ jobs:
- name: Build
run: pnpm build

- name: Create disposable test schema
run: pnpm exec prisma db push --skip-generate

- name: Run Jest suite
run: pnpm test --ci --runInBand

invoice-protocol:
runs-on: ubuntu-latest
services:
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ services:
container_name: accesslayer-postgres
restart: unless-stopped
environment:
POSTGRES_DB: accesslayer
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: accesslayer_test
POSTGRES_USER: accesslayer
POSTGRES_PASSWORD: local-test-only
ports:
- '5432:5432'
volumes:
Expand Down
32 changes: 31 additions & 1 deletion docs/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

Base URL: `http://localhost:3000/api/v1`

## Tips Endpoints

### GET /tips/:tip_id/content

Get the message content associated with a `TipSent` event. The first request
fetches the event's IPFS content hash through the configured Pinata gateway and
caches the result permanently in `tip_content`. Cached content is not fetched
again. Content is limited to 500 characters when stored.

- **Auth:** None
- **Response:** `200 OK` when content is fetched or unavailable; `404 Not Found`
when the tip does not exist

```json
{
"success": true,
"data": {
"tip_id": "tip-123",
"content": "Thanks for your work!",
"content_unavailable": false
}
}
```

If the IPFS request times out after 5 seconds (or the gateway cannot provide
the content), `content` is `null` and `content_unavailable` is `true`. Failed
fetches are not cached.

## Health Endpoints

### GET /health
Expand Down Expand Up @@ -719,7 +747,9 @@ Cached for 60 seconds.
"success": true,
"data": {
"total_balance": "1200000000",
"per_source_breakdown": [{ "source": "trading", "balance": "1500000000" }],
"per_source_breakdown": [
{ "source": "trading", "balance": "1500000000" }
],
"last_updated": "2026-10-21T10:00:00.000Z"
}
}
Expand Down
26 changes: 16 additions & 10 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
const { createDefaultPreset } = require('ts-jest');

const tsJestTransformCfg = createDefaultPreset().transform;
const tsJestTransformCfg = createDefaultPreset({
diagnostics: { warnOnly: true },
tsconfig: '<rootDir>/tsconfig.jest.json',
}).transform;

/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: 'node',
transform: {
...tsJestTransformCfg,
},
globals: {
'ts-jest': {
// Treat TypeScript type errors as warnings rather than hard failures.
// This allows the test suite to run against files that have pre-existing
// type errors (e.g. schema mismatches not yet addressed) without blocking
// all integration tests that import those modules transitively.
diagnostics: { warnOnly: true },
},
},
maxWorkers: 2,
workerIdleMemoryLimit: '512MB',
moduleNameMapper: {
'^chalk$': '<rootDir>/src/__mocks__/chalk.ts',
},
roots: ['<rootDir>/src'],
setupFiles: ['./jest.setup.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.integration.setup.ts'],
testEnvironmentOptions: {
// Jest 30 soft-deletes globals between test files, which recursively
// rewrites accessors on long-lived objects (e.g. EventEmitter
// `_eventsCount`) and blows the stack with
// "RangeError: Maximum call stack size exceeded" when the whole suite
// runs in a single process (--runInBand in CI). Cleanup is disabled so
// a leaked global cannot crash the runner.
globalsCleanup: 'off',
},
};
28 changes: 28 additions & 0 deletions jest.integration.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Runs after each test file finishes (setupFilesAfterEnv).
//
// Files that call `jest.unmock('@prisma/client')` get a real PrismaClient
// with a live connection pool. Without an explicit disconnect those sockets
// outlive the test file's module registry and accumulate across the
// --runInBand run until Postgres refuses new connections. This hook closes
// the client's pool after every file. For files still on the Prisma mock it
// is a harmless call to the mock's `$disconnect`.
const testPath = expect.getState().testPath ?? '';
const normalizedTestPath = testPath.replace(/\\/g, '/');
const isIntegrationTest =
normalizedTestPath.includes('/integration/') ||
normalizedTestPath.endsWith('.integration.test.ts');

if (isIntegrationTest) {
jest.unmock('@prisma/client');

afterAll(async () => {
const { prisma } = await import('./src/utils/prisma.utils');
if (!prisma) {
return;
}

if (typeof prisma.$disconnect === 'function') {
await prisma.$disconnect();
}
});
}
2 changes: 1 addition & 1 deletion jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Values are non-functional placeholders sufficient for schema validation.

process.env.MODE ??= 'test';
process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test';
process.env.DATABASE_URL ??= 'postgresql://accesslayer:local-test-only@localhost:5432/accesslayer_test';
process.env.GMAIL_USER ??= 'test@example.com';
process.env.GMAIL_APP_PASSWORD ??= 'test-password';
process.env.GOOGLE_CLIENT_ID ??= 'test-google-client-id';
Expand Down
1 change: 1 addition & 0 deletions prisma/schema/activity.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum ActivityType {
GRADUATED_CURVE_CONFIGURED
KEY_DEPRECATED
SUPPLY_FULLY_FUNDED
MILESTONE_CROSSED
}

model Activity {
Expand Down
1 change: 1 addition & 0 deletions prisma/schema/merkle.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ model Event {
@@index([partitionId])
@@index([network, contractId])
@@index([ledger])
@@index([topic])
}

model MerkleTreeState {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE "tip_content" (
"id" TEXT NOT NULL,
"tip_id" TEXT NOT NULL,
"content_hash" TEXT NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "tip_content_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "tip_content_tip_id_key" ON "tip_content"("tip_id");
CREATE INDEX "Event_topic_idx" ON "Event"("topic");
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TYPE "ActivityType" ADD VALUE IF NOT EXISTS 'MILESTONE_CROSSED';
9 changes: 9 additions & 0 deletions prisma/schema/tip-content.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
model TipContent {
id String @id @default(cuid())
tipId String @unique @map("tip_id")
contentHash String @map("content_hash")
content String
createdAt DateTime @default(now()) @map("created_at")

@@map("tip_content")
}
24 changes: 24 additions & 0 deletions src/clients/soroban.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,30 @@ async function callRpc<T>(
params: unknown,
postJson: SorobanPostJson
): Promise<T> {
// Skip circuit breaker in test mode unless explicitly enabled
if (envConfig.MODE === 'test' && process.env.ENABLE_SOROBAN_CIRCUIT_BREAKER !== '1') {
const url = envConfig.STELLAR_SOROBAN_RPC_URL;
const body = { jsonrpc: '2.0', id: '1', method, params };

let envelope: unknown;
try {
envelope = await postJson(url, body);
} catch (error) {
logger.warn(
{ method, error: (error as Error).message },
'Soroban RPC request failed'
);
throw error;
}

const { result, error } = (envelope ?? {}) as JsonRpcEnvelope;
if (error) {
throw new SorobanRpcMethodError(error.code, error.message);
}

return result as T;
}

return sorobanRpcCircuitBreaker.execute(async () => {
const url = envConfig.STELLAR_SOROBAN_RPC_URL;
const body = { jsonrpc: '2.0', id: '1', method, params };
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/deprecation.middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@ function run() {
console.log('deprecation.middleware tests passed');
}

run();
test('sets the configured deprecation response headers', run);
2 changes: 1 addition & 1 deletion src/middlewares/schema-version.middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,4 @@ function run() {
console.log('schema-version.middleware tests passed');
}

run();
test('sets the schema version header when enabled', run);
2 changes: 1 addition & 1 deletion src/modules/acl/acl.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function verifyAclSignatures(params: {
}
if (validWallets.length < ACL_MULTISIG_THRESHOLD) {
throw new MultisigVerificationError(
`ACL mutations require ${ACL_MULTISIG_THRESHOLD} of ${adminSet.size || ACL_MULTISIG_SET_SIZE} admin signatures; got ${validWallets.length}`
`ACL mutation requires ${ACL_MULTISIG_THRESHOLD} of ${adminSet.size || ACL_MULTISIG_SET_SIZE} admin signatures; got ${validWallets.length}`
);
}

Expand Down
Loading
Loading