Skip to content

Commit ee32ecb

Browse files
authored
Merge pull request #589 from itsjaneossai/Stellar
Stellar
2 parents 80a0342 + 053811a commit ee32ecb

17 files changed

Lines changed: 1168 additions & 2 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
name: Contract Tests
2+
3+
on:
4+
push:
5+
branches: [main, develop]
6+
paths:
7+
- 'src/**'
8+
- 'test/contracts/**'
9+
- '.github/workflows/contract-tests.yml'
10+
pull_request:
11+
branches: [main]
12+
repository_dispatch:
13+
types: [pact-changed]
14+
15+
env:
16+
NODE_VERSION: '20'
17+
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
18+
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
19+
20+
jobs:
21+
consumer-contracts:
22+
name: Consumer Contract Tests
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v4
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: ${{ env.NODE_VERSION }}
29+
cache: 'npm'
30+
- run: npm ci
31+
- name: Run consumer contract tests
32+
run: npm run test:consumer
33+
env:
34+
CONSUMER_VERSION: ${{ github.sha }}
35+
GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
36+
- name: Upload pact files
37+
if: always()
38+
uses: actions/upload-artifact@v4
39+
with:
40+
name: pact-files
41+
path: pact/contracts/
42+
retention-days: 30
43+
- name: Publish pacts to Pact Broker
44+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
45+
run: npm run pact:publish
46+
env:
47+
CONSUMER_VERSION: ${{ github.sha }}
48+
GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
49+
- name: Can I Deploy? (Consumer)
50+
if: github.event_name != 'pull_request'
51+
run: |
52+
npx pact-broker can-i-deploy \
53+
--pacticipant ApiGateway \
54+
--version ${{ github.sha }} \
55+
--to-environment production \
56+
--broker-base-url ${{ env.PACT_BROKER_URL }} \
57+
--broker-token ${{ env.PACT_BROKER_TOKEN }}
58+
59+
provider-verification:
60+
name: Provider Verification
61+
runs-on: ubuntu-latest
62+
needs: consumer-contracts
63+
if: always() && (needs.consumer-contracts.result == 'success' || github.event_name == 'repository_dispatch')
64+
services:
65+
user-service:
66+
image: strellerminds/user-service:latest
67+
ports:
68+
- 3001:3001
69+
env:
70+
NODE_ENV: test
71+
options: >-
72+
--health-cmd "curl -f http://localhost:3001/health || exit 1"
73+
--health-interval 10s
74+
--health-timeout 5s
75+
--health-retries 5
76+
auth-service:
77+
image: strellerminds/auth-service:latest
78+
ports:
79+
- 3002:3002
80+
env:
81+
NODE_ENV: test
82+
options: >-
83+
--health-cmd "curl -f http://localhost:3002/health || exit 1"
84+
--health-interval 10s
85+
--health-timeout 5s
86+
--health-retries 5
87+
postgres:
88+
image: postgres:15
89+
env:
90+
POSTGRES_USER: test
91+
POSTGRES_PASSWORD: test
92+
POSTGRES_DB: testdb
93+
options: >-
94+
--health-cmd pg_isready
95+
--health-interval 10s
96+
--health-timeout 5s
97+
--health-retries 5
98+
steps:
99+
- uses: actions/checkout@v4
100+
- uses: actions/setup-node@v4
101+
with:
102+
node-version: ${{ env.NODE_VERSION }}
103+
cache: 'npm'
104+
- run: npm ci
105+
- run: sleep 10
106+
- name: Verify UserService
107+
run: npm run test:provider -- --testPathPattern=user-service
108+
env:
109+
CI: true
110+
USER_SERVICE_URL: http://localhost:3001
111+
PROVIDER_VERSION: ${{ github.sha }}
112+
GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
113+
- name: Verify AuthService
114+
run: npm run test:provider -- --testPathPattern=auth-service
115+
env:
116+
CI: true
117+
AUTH_SERVICE_URL: http://localhost:3002
118+
PROVIDER_VERSION: ${{ github.sha }}
119+
GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
120+
- name: Can I Deploy? (Provider)
121+
if: github.event_name != 'pull_request'
122+
run: |
123+
npx pact-broker can-i-deploy \
124+
--pacticipant UserService \
125+
--version ${{ github.sha }} \
126+
--to-environment production \
127+
--broker-base-url ${{ env.PACT_BROKER_URL }} \
128+
--broker-token ${{ env.PACT_BROKER_TOKEN }}
129+
130+
generate-docs:
131+
name: Generate Contract Documentation
132+
runs-on: ubuntu-latest
133+
needs: [consumer-contracts, provider-verification]
134+
if: github.ref == 'refs/heads/main' && success()
135+
steps:
136+
- uses: actions/checkout@v4
137+
- uses: actions/setup-node@v4
138+
with:
139+
node-version: ${{ env.NODE_VERSION }}
140+
cache: 'npm'
141+
- run: npm ci
142+
- uses: actions/download-artifact@v4
143+
with:
144+
name: pact-files
145+
path: pact/contracts/
146+
- run: npm run docs:generate
147+
- uses: actions/upload-artifact@v4
148+
with:
149+
name: contract-docs
150+
path: docs/contracts/
151+
retention-days: 90
152+
- name: Deploy docs to GitHub Pages
153+
if: github.ref == 'refs/heads/main'
154+
uses: peaceiris/actions-gh-pages@v3
155+
with:
156+
github_token: ${{ secrets.GITHUB_TOKEN }}
157+
publish_dir: ./docs/contracts
158+
destination_dir: contract-docs

CONTRACTS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#Contract Testing for StrellerMinds Smart Contracts
2+
3+
##Issue #471 - Status: In Progress
4+
5+
Goal: Add contract testing between microservices and Soroban smart contracts.
6+
7+
### Current Approach (Phone-Friendly Start)
8+
- Use *Consumer-Driven Contracts* where possible.
9+
- Start with OpenAPI/Swagger schema validation for REST endpoints.
10+
- Plan to migrate to **Pact** for full behavioral testing.
11+
12+
### Key Contracts to Cover
13+
- Soroban contract invocation endpoints
14+
- User authentication & progress APIs
15+
- Transaction submission
16+
17+
### Basic Validation Setup
18+
Add to `package.json` (if Node.js parts exist) or CI:
19+
```json
20+
"scripts": {
21+
"test:contract": "echo 'Contract tests will run here - using Pact or Spectral for OpenAPI'"
22+
}

jest.contract.config.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module.exports = {
2+
displayName: 'contract-tests',
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
rootDir: '.',
6+
testMatch: [
7+
'**/test/contracts/**/*.spec.ts',
8+
'**/test/contracts/**/*.test.ts',
9+
],
10+
moduleNameMapper: {
11+
'@shared/(.*)': '<rootDir>/src/shared/$1',
12+
},
13+
globals: {
14+
'ts-jest': {
15+
tsconfig: './tsconfig.json',
16+
},
17+
},
18+
testTimeout: 30000,
19+
maxWorkers: 1,
20+
verbose: true,
21+
};

package.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
"visual:test": "playwright test --config=visual-tests/playwright.config.ts",
44
"visual:test:update": "playwright test --config=visual-tests/playwright.config.ts --update-snapshots",
55
"visual:test:report": "playwright show-report visual-tests/playwright-report",
6+
"test:contracts": "jest --config jest.contract.config.js",
7+
"test:consumer": "jest --config jest.contract.config.js --testPathPattern=consumer",
8+
"test:provider": "jest --config jest.contract.config.js --testPathPattern=provider",
9+
"pact:publish": "ts-node src/scripts/publish-pacts.ts",
10+
"docs:generate": "ts-node src/scripts/generate-docs.ts",
611
"a11y:test": "playwright test --config=visual-tests/accessibility.config.ts",
712
"a11y:report": "playwright show-report visual-tests/accessibility-report"
813
},
@@ -13,6 +18,12 @@
1318
"@types/node": "^24.6.2",
1419
"axe-core": "^4.11.3",
1520
"axios": "^1.12.2",
16-
"dotenv": "^17.2.3"
21+
"dotenv": "^17.2.3",
22+
"@pact-foundation/pact": "^12.0.0",
23+
"@pact-foundation/pact-node": "^10.17.7",
24+
"jest": "^29.5.0",
25+
"ts-jest": "^29.1.0",
26+
"ts-node": "^10.9.0",
27+
"typescript": "^5.0.0"
28+
}
1729
}
18-
}

pact/contracts/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

pact/logs/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
2+
3+
interface ProviderStateRequest {
4+
state: string;
5+
action: 'setup' | 'teardown';
6+
params?: Record<string, unknown>;
7+
}
8+
9+
@Controller('_pact')
10+
export class PactProviderStatesController {
11+
private readonly stateHandlers: Record
12+
string,
13+
{ setup: () => Promise<void>; teardown?: () => Promise<void> }
14+
> = {
15+
'a user with id user-abc-123 exists': {
16+
setup: async () => {
17+
// TODO: await userRepo.save({ id: 'user-abc-123', ... })
18+
},
19+
teardown: async () => {
20+
// TODO: await userRepo.delete('user-abc-123')
21+
},
22+
},
23+
'a user with id nonexistent does not exist': {
24+
setup: async () => {
25+
// TODO: ensure user doesn't exist
26+
},
27+
},
28+
'users exist in the system': {
29+
setup: async () => {
30+
// TODO: seed test users
31+
},
32+
},
33+
'the user system is ready to accept new users': {
34+
setup: async () => {
35+
// TODO: clear conflicting data
36+
},
37+
},
38+
};
39+
40+
@Post('provider-states')
41+
@HttpCode(200)
42+
async handleProviderState(@Body() body: ProviderStateRequest) {
43+
const { state, action } = body;
44+
const handler = this.stateHandlers[state];
45+
46+
if (!handler) {
47+
console.warn(`[Pact] No handler for state: "${state}"`);
48+
return { state, result: 'no-op' };
49+
}
50+
51+
if (action === 'setup') await handler.setup();
52+
else if (action === 'teardown' && handler.teardown) await handler.teardown();
53+
54+
return { state, result: 'ok' };
55+
}
56+
}

src/scripts/generate-docs.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
const PACT_DIR = path.resolve(process.cwd(), 'pact/contracts');
5+
const DOCS_DIR = path.resolve(process.cwd(), 'docs/contracts');
6+
7+
interface PactFile {
8+
consumer: { name: string };
9+
provider: { name: string };
10+
interactions: Array<{
11+
description: string;
12+
providerState?: string;
13+
request: { method: string; path: string; headers?: Record<string,string>; body?: unknown };
14+
response: { status: number; headers?: Record<string,string>; body?: unknown };
15+
}>;
16+
metadata: { pactSpecification: { version: string } };
17+
}
18+
19+
function generateDoc(pact: PactFile, filename: string): string {
20+
let doc = `# Contract: ${pact.consumer.name}${pact.provider.name}\n\n`;
21+
doc += `> Auto-generated from \`${filename}\`\n\n`;
22+
doc += `| Property | Value |\n|---|---|\n`;
23+
doc += `| Consumer | ${pact.consumer.name} |\n`;
24+
doc += `| Provider | ${pact.provider.name} |\n`;
25+
doc += `| Interactions | ${pact.interactions.length} |\n\n`;
26+
doc += `## Interactions\n\n`;
27+
28+
pact.interactions.forEach((i) => {
29+
doc += `### ${i.description}\n\n`;
30+
if (i.providerState) doc += `> **State:** ${i.providerState}\n\n`;
31+
doc += `**Request:** \`${i.request.method} ${i.request.path}\`\n\n`;
32+
doc += `**Response:** \`HTTP ${i.response.status}\`\n\n`;
33+
if (i.response.body) doc += `\`\`\`json\n${JSON.stringify(i.response.body, null, 2)}\n\`\`\`\n\n`;
34+
doc += `---\n\n`;
35+
});
36+
37+
return doc;
38+
}
39+
40+
async function generateDocs(): Promise<void> {
41+
fs.mkdirSync(DOCS_DIR, { recursive: true });
42+
43+
if (!fs.existsSync(PACT_DIR)) {
44+
console.warn('No pact directory found. Run consumer tests first.');
45+
return;
46+
}
47+
48+
const files = fs.readdirSync(PACT_DIR).filter((f) => f.endsWith('.json'));
49+
50+
files.forEach((file) => {
51+
const pact: PactFile = JSON.parse(fs.readFileSync(path.join(PACT_DIR, file), 'utf-8'));
52+
const doc = generateDoc(pact, file);
53+
fs.writeFileSync(path.join(DOCS_DIR, file.replace('.json', '.md')), doc);
54+
console.log(`✅ ${file.replace('.json', '.md')}`);
55+
});
56+
57+
console.log(`\nDocs generated in ${DOCS_DIR}`);
58+
}
59+
60+
generateDocs().catch(console.error);

src/scripts/publish-pacts.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Publisher } from '@pact-foundation/pact-node';
2+
import { PACT_CONFIG } from '../shared/pact.config';
3+
4+
async function publishPacts(): Promise<void> {
5+
const version = process.env.CONSUMER_VERSION || '1.0.0';
6+
const branch = process.env.GIT_BRANCH || 'main';
7+
8+
console.log(`Publishing pacts v${version} (branch: ${branch})...`);
9+
10+
await new Publisher({
11+
pactFilesOrDirs: [PACT_CONFIG.PACT_DIR],
12+
pactBroker: PACT_CONFIG.BROKER_URL,
13+
pactBrokerToken: PACT_CONFIG.BROKER_TOKEN,
14+
consumerVersion: version,
15+
branch,
16+
tags: [branch, 'latest'],
17+
}).publishPacts();
18+
19+
console.log('✅ Pacts published');
20+
}
21+
22+
publishPacts().catch((err) => {
23+
console.error('Failed to publish pacts:', err);
24+
process.exit(1);
25+
});

0 commit comments

Comments
 (0)