From e3a09dcabadd7ba79d3b66d55985f19d3f03c118 Mon Sep 17 00:00:00 2001 From: Acredia Developer Date: Mon, 29 Jun 2026 14:28:43 +0100 Subject: [PATCH 1/2] feat(backend): publish versioned OpenAPI spec --- .github/workflows/backend-ci.yml | 16 ++++++++++ BackEnd/docs/openapi.json | 42 ++++++++++++++++++++++++ BackEnd/package.json | 1 + BackEnd/src/app.module.ts | 4 ++- BackEnd/src/config/swagger.config.ts | 6 +++- BackEnd/src/generate-openapi.ts | 48 ++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 BackEnd/docs/openapi.json create mode 100644 BackEnd/src/generate-openapi.ts diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index fb341a255..7408353c9 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -44,6 +44,9 @@ jobs: - name: TypeScript compilation run: npm run build + - name: Generate OpenAPI spec + run: npm run generate:openapi + - name: Upload build artifact uses: actions/upload-artifact@v4 with: @@ -51,6 +54,19 @@ jobs: path: BackEnd/dist/ retention-days: 7 + - name: Upload OpenAPI artifact + uses: actions/upload-artifact@v4 + with: + name: backend-openapi-spec + path: BackEnd/docs/openapi.json + retention-days: 7 + + - name: Check OpenAPI spec diff + if: github.event_name == 'pull_request' + run: | + git diff --exit-code -- docs/openapi.json + shell: bash + - name: Lint run: npm run lint diff --git a/BackEnd/docs/openapi.json b/BackEnd/docs/openapi.json new file mode 100644 index 000000000..8d5612550 --- /dev/null +++ b/BackEnd/docs/openapi.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "StellarEarn API", + "description": "Quest-based earning platform on Stellar blockchain\n\nSupported API versions: v1, v2. Use path versioning (/api/v1/*, /api/v2/*) and/or header versioning (X-API-Version: 1).", + "version": "0.0.1" + }, + "servers": [ + { + "url": "/api/v1", + "description": "API v1" + }, + { + "url": "/api/v2", + "description": "API v2" + } + ], + "tags": [ + { + "name": "Authentication" + }, + { + "name": "Health", + "description": "System health and readiness probes" + } + ], + "paths": {}, + "components": { + "securitySchemes": { + "JWT-auth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "security": [ + { + "JWT-auth": [] + } + ] +} diff --git a/BackEnd/package.json b/BackEnd/package.json index 7ee529fe3..0cbb54d5c 100644 --- a/BackEnd/package.json +++ b/BackEnd/package.json @@ -24,6 +24,7 @@ "test:integration": "jest --config ./test/jest-integration.json", "db:backup": "ts-node scripts/db-backup.ts", "db:rollback": "ts-node scripts/db-rollback.ts", + "generate:openapi": "nest start --entryFile generate-openapi", "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts", "migration:generate": "bun run typeorm -- migration:generate", "migration:create": "typeorm migration:create", diff --git a/BackEnd/src/app.module.ts b/BackEnd/src/app.module.ts index 9db258661..419c8aee5 100644 --- a/BackEnd/src/app.module.ts +++ b/BackEnd/src/app.module.ts @@ -36,13 +36,15 @@ import { WebsocketModule } from './modules/websocket/websocket.module'; import { TraceInterceptor } from './modules/trace/trace.interceptor'; import { EventsModule } from './events/events.module'; +const isOpenApiGeneration = process.env.GENERATE_OPENAPI === 'true'; + @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), - TypeOrmModule.forRoot(dataSourceOptions), + ...(isOpenApiGeneration ? [] : [TypeOrmModule.forRoot(dataSourceOptions)]), LoggerModule.forRoot(), EventsModule, AdminModule, diff --git a/BackEnd/src/config/swagger.config.ts b/BackEnd/src/config/swagger.config.ts index 03f2ee7c7..3af8f3b73 100644 --- a/BackEnd/src/config/swagger.config.ts +++ b/BackEnd/src/config/swagger.config.ts @@ -2,12 +2,14 @@ import { INestApplication } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; +const packageJson = require('../../package.json') as { version: string }; + export function setupSwagger( app: INestApplication, configService?: ConfigService, ) { const title = configService?.get('APP_NAME') || 'StellarEarn API'; - const version = configService?.get('API_VERSION') || '1.0'; + const version = configService?.get('API_VERSION') || packageJson.version || '1.0'; const description = configService?.get('API_DESCRIPTION') || 'Quest-based earning platform on Stellar blockchain'; @@ -34,4 +36,6 @@ export function setupSwagger( SwaggerModule.setup('api/docs', app, document, { swaggerOptions: { persistAuthorization: true }, }); + + return document; } diff --git a/BackEnd/src/generate-openapi.ts b/BackEnd/src/generate-openapi.ts new file mode 100644 index 000000000..346eb94f7 --- /dev/null +++ b/BackEnd/src/generate-openapi.ts @@ -0,0 +1,48 @@ +import { VersioningType } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + API_VERSION_CONFIG, + extractApiVersion, +} from './config/versioning.config'; +import { setupSwagger } from './config/swagger.config'; + +async function generateOpenApiSpec(): Promise { + process.env.GENERATE_OPENAPI = 'true'; + + const { AppModule } = await import('./app.module'); + + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + const configService = app.get(ConfigService); + + app.setGlobalPrefix('api'); + app.enableVersioning({ + type: VersioningType.CUSTOM, + defaultVersion: API_VERSION_CONFIG.defaultVersion, + extractor: (request) => { + return ( + extractApiVersion(request as any) || API_VERSION_CONFIG.defaultVersion + ); + }, + }); + + const document = setupSwagger(app, configService); + + const outputPath = path.resolve(process.cwd(), 'docs/openapi.json'); + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.promises.writeFile(outputPath, `${JSON.stringify(document, null, 2)}\n`); + + console.log(`OpenAPI spec written to ${outputPath}`); + + await app.close(); +} + +generateOpenApiSpec().catch((error) => { + console.error('Failed to generate OpenAPI spec', error); + process.exit(1); +}); From 768d3b61dc60e9188ab244e09a141b1ccfe72a44 Mon Sep 17 00:00:00 2001 From: Acredia Developer Date: Tue, 30 Jun 2026 07:22:31 +0100 Subject: [PATCH 2/2] test: add critical Playwright user journeys --- .github/workflows/frontend-ci.yml | 7 + FrontEnd/my-app/context/WalletContext.tsx | 32 +++ FrontEnd/my-app/package.json | 1 + FrontEnd/my-app/playwright.config.ts | 8 +- .../my-app/tests/e2e/helpers/mock-wallet.ts | 226 ++++++++++++++++++ .../my-app/tests/e2e/quest-journey.spec.ts | 59 +++++ .../my-app/tests/e2e/wallet-connect.spec.ts | 23 ++ 7 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 FrontEnd/my-app/tests/e2e/helpers/mock-wallet.ts create mode 100644 FrontEnd/my-app/tests/e2e/quest-journey.spec.ts create mode 100644 FrontEnd/my-app/tests/e2e/wallet-connect.spec.ts diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 41d09048a..bef7ed279 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -58,6 +58,13 @@ jobs: - name: Tests run: npm run test + # Install Playwright browser and run critical E2E journeys + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: E2E tests + run: npm run test:e2e -- --project=chromium + # Build Next.js app - name: Build run: npm run build diff --git a/FrontEnd/my-app/context/WalletContext.tsx b/FrontEnd/my-app/context/WalletContext.tsx index 2190e4149..27434714c 100644 --- a/FrontEnd/my-app/context/WalletContext.tsx +++ b/FrontEnd/my-app/context/WalletContext.tsx @@ -24,6 +24,27 @@ interface WalletContextType { const WalletContext = createContext(undefined); +const createMockWalletKit = () => { + const mockAddress = + (typeof window !== 'undefined' && + (window as Window & { + __PLAYWRIGHT_MOCK_WALLET_ADDRESS__?: string; + }).__PLAYWRIGHT_MOCK_WALLET_ADDRESS__) || + 'GCLN3QY2X7V4R5J6K8M9P0Q1R2S3T4U5V6W7X8Y9Z0A1B2C3D4E5F6G7H8J9K'; + + return { + setWallet: async () => undefined, + getAddress: async () => ({ address: mockAddress }), + disconnect: async () => undefined, + sign: async ({ payload }: { payload: string }) => ({ + result: `mock-signature:${payload}`, + }), + signTransaction: async (xdr: string) => ({ + signedTxXdr: `mock-signed:${xdr}`, + }), + }; +}; + export const useWallet = () => { const context = useContext(WalletContext); if (!context) @@ -52,6 +73,17 @@ export const WalletProvider = ({ children }: { children: React.ReactNode }) => { useEffect(() => { const initKit = async () => { + const isMockWalletEnabled = + typeof window !== 'undefined' && + (window as Window & { + __PLAYWRIGHT_MOCK_WALLET__?: string; + }).__PLAYWRIGHT_MOCK_WALLET__ === 'true'; + + if (isMockWalletEnabled) { + setKit(createMockWalletKit()); + return; + } + try { const walletKitModule = await import('@creit.tech/stellar-wallets-kit'); const kitInstance = new walletKitModule.StellarWalletsKit({ diff --git a/FrontEnd/my-app/package.json b/FrontEnd/my-app/package.json index 601eb0ca8..aa51cd695 100644 --- a/FrontEnd/my-app/package.json +++ b/FrontEnd/my-app/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit", "validate:path-aliases": "node scripts/validate-path-aliases.js", "test": "vitest run", + "test:e2e": "playwright test --config=playwright.config.ts", "test:unit": "vitest run --exclude '**/*.integration.test.ts'", "test:integration": "vitest run --include '**/*.integration.test.ts'", "test:watch": "vitest", diff --git a/FrontEnd/my-app/playwright.config.ts b/FrontEnd/my-app/playwright.config.ts index 616a8fcd0..0a184a613 100644 --- a/FrontEnd/my-app/playwright.config.ts +++ b/FrontEnd/my-app/playwright.config.ts @@ -8,9 +8,15 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', + baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3000', trace: 'on-first-retry', }, + webServer: { + command: 'npm run dev -- --hostname 127.0.0.1', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, projects: [ { name: 'chromium', diff --git a/FrontEnd/my-app/tests/e2e/helpers/mock-wallet.ts b/FrontEnd/my-app/tests/e2e/helpers/mock-wallet.ts new file mode 100644 index 000000000..0df211d89 --- /dev/null +++ b/FrontEnd/my-app/tests/e2e/helpers/mock-wallet.ts @@ -0,0 +1,226 @@ +import type { Page, Route } from '@playwright/test'; + +const DEFAULT_ADDRESS = 'GCLN3QY2X7V4R5J6K8M9P0Q1R2S3T4U5V6W7X8Y9Z0A1B2C3D4E5F6G7H8J9K'; +const DEFAULT_CHALLENGE = 'playwright-e2e-challenge'; + +const MOCK_QUEST = { + id: 'quest-1', + contractQuestId: '1', + title: 'Documentation Quest', + description: 'Write documentation for the StellarEarn platform.', + category: 'Docs', + difficulty: 'beginner', + rewardAmount: '100', + rewardAsset: 'XLM', + xpReward: 50, + status: 'Active', + deadline: new Date(Date.now() + 7 * 86_400_000).toISOString(), + verifierAddress: 'GCFX7M4YVQQ2TESTVERIFYADDRESS7XQK3Q2J7W3R6CQJ6H3TL5E3QWIZARD', + requirements: ['Submit a pull request URL'], + maxParticipants: 10, + currentParticipants: 0, + totalClaims: 0, + totalSubmissions: 0, + approvedSubmissions: 0, + rejectedSubmissions: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const MOCK_PROFILE = { + id: 'user-1', + stellarAddress: DEFAULT_ADDRESS, + username: 'playwright-user', + email: 'playwright@example.com', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +export async function mockWalletAndApi(page: Page) { + await page.addInitScript( + ({ address, challenge }) => { + localStorage.setItem( + 'stellar_earn_analytics_consent', + JSON.stringify({ status: 'denied', version: '1' }) + ); + localStorage.removeItem('stellar_earn_access_token'); + localStorage.removeItem('stellar_earn_refresh_token'); + (window as typeof window & { + __PLAYWRIGHT_MOCK_WALLET__?: string; + __PLAYWRIGHT_MOCK_WALLET_ADDRESS__?: string; + __PLAYWRIGHT_MOCK_CHALLENGE__?: string; + }).__PLAYWRIGHT_MOCK_WALLET__ = 'true'; + (window as typeof window & { + __PLAYWRIGHT_MOCK_WALLET__?: string; + __PLAYWRIGHT_MOCK_WALLET_ADDRESS__?: string; + __PLAYWRIGHT_MOCK_CHALLENGE__?: string; + }).__PLAYWRIGHT_MOCK_WALLET_ADDRESS__ = address; + (window as typeof window & { + __PLAYWRIGHT_MOCK_WALLET__?: string; + __PLAYWRIGHT_MOCK_WALLET_ADDRESS__?: string; + __PLAYWRIGHT_MOCK_CHALLENGE__?: string; + }).__PLAYWRIGHT_MOCK_CHALLENGE__ = challenge; + }, + { address: DEFAULT_ADDRESS, challenge: DEFAULT_CHALLENGE } + ); + + await page.route('**/api/v1/auth/challenge', async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + challenge: DEFAULT_CHALLENGE, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), + }); + }); + + await page.route('**/api/v1/auth/login', async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + }), + }); + }); + + await page.route('**/api/v1/auth/profile', async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_PROFILE), + }); + }); + + await page.route('**/api/v1/auth/logout', async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ message: 'Logged out' }), + }); + }); + + await page.route('**/api/v1/quests**', async (route: Route) => { + const request = route.request(); + if (request.method() !== 'GET') { + await route.fallback(); + return; + } + + const pathname = new URL(request.url()).pathname; + if (pathname === '/api/v1/quests') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + quests: [MOCK_QUEST], + total: 1, + page: 1, + limit: 12, + totalPages: 1, + }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_QUEST), + }); + }); + + await page.route('**/api/v1/submissions**', async (route: Route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + + if (request.method() === 'GET' && pathname === '/api/v1/submissions') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + submissions: [ + { + id: 'SUB-1001', + questId: 'quest-1', + questTitle: 'Documentation Quest', + status: 'Pending', + proofUrl: 'https://example.com/proof.txt', + createdAt: new Date().toISOString(), + }, + ], + total: 1, + }), + }); + return; + } + + if (request.method() === 'POST' && pathname === '/api/v1/submissions') { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 'SUB-1002', + questId: 'quest-1', + questTitle: 'Documentation Quest', + status: 'Pending', + proofUrl: 'https://example.com/proof.txt', + createdAt: new Date().toISOString(), + }), + }); + return; + } + + if (request.method() === 'POST' && pathname === '/api/v1/submissions/upload') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'upload-1', url: 'https://example.com/proof.txt' }), + }); + return; + } + + await route.fallback(); + }); + + await page.route('**/api/v1/rewards**', async (route: Route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + + if (request.method() === 'GET' && pathname === '/api/v1/rewards') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + rewards: [ + { + id: 'reward-1', + questTitle: 'Documentation Quest', + amount: '100', + asset: 'XLM', + status: 'Pending', + }, + ], + total: 1, + }), + }); + return; + } + + if (request.method() === 'POST' && pathname === '/api/v1/rewards/claim') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + transactionHash: '0xmocktxhash', + status: 'Success', + }), + }); + return; + } + + await route.fallback(); + }); +} diff --git a/FrontEnd/my-app/tests/e2e/quest-journey.spec.ts b/FrontEnd/my-app/tests/e2e/quest-journey.spec.ts new file mode 100644 index 000000000..b11debe41 --- /dev/null +++ b/FrontEnd/my-app/tests/e2e/quest-journey.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { mockWalletAndApi } from './helpers/mock-wallet'; + +test.describe('Critical quest journey', () => { + test.beforeEach(async ({ page }) => { + await mockWalletAndApi(page); + }); + + test('discovers a quest and opens its detail view', async ({ page }) => { + await page.goto('/quests'); + + await expect(page.getByRole('heading', { name: 'Quest Board', level: 1 })).toBeVisible(); + + const questCard = page.locator('[role="button"][aria-label^="View quest:"]').first(); + await expect(questCard).toBeVisible(); + await questCard.click(); + + await expect(page).toHaveURL(/\/quests\/[^/]+$/); + await expect(page.getByRole('heading', { name: /documentation quest/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /back to quests/i })).toBeVisible(); + }); + + test('creates a submission and shows it in the submissions list', async ({ page }) => { + await page.goto('/submissions'); + + await page.getByRole('button', { name: /new submission/i }).click(); + + const dialog = page.getByRole('dialog', { name: /submission details|submit proof/i }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /start quest/i }).click(); + + await dialog.locator('input[type="file"]').setInputFiles({ + name: 'proof.txt', + mimeType: 'text/plain', + buffer: Buffer.from('proof of work'), + }); + + await dialog.getByRole('button', { name: /submit work for quest/i }).click(); + await expect(dialog).toBeHidden({ timeout: 10_000 }); + await expect(page.getByRole('row', { name: /SUB-1002/i })).toBeVisible(); + }); + + test('claims a reward after the wallet-backed submission flow', async ({ page }) => { + await page.goto('/rewards'); + + await page.evaluate(() => { + Math.random = () => 0.5; + }); + + await page.getByRole('button', { name: /claim all rewards/i }).click(); + + const dialog = page.getByRole('dialog', { name: /claim transaction/i }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('heading', { name: /claim successful/i })).toBeVisible({ timeout: 10_000 }); + + await dialog.getByRole('button', { name: /^Done$/ }).click(); + await expect(page.getByRole('cell', { name: /^Success$/ })).toBeVisible(); + }); +}); diff --git a/FrontEnd/my-app/tests/e2e/wallet-connect.spec.ts b/FrontEnd/my-app/tests/e2e/wallet-connect.spec.ts new file mode 100644 index 000000000..d7889efb0 --- /dev/null +++ b/FrontEnd/my-app/tests/e2e/wallet-connect.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { mockWalletAndApi } from './helpers/mock-wallet'; + +test.describe('Wallet connect journey', () => { + test.beforeEach(async ({ page }) => { + await mockWalletAndApi(page); + }); + + test('connects a wallet and completes the signed authentication step', async ({ page }) => { + await page.goto('/'); + + await page.locator('button[aria-label="Connect wallet"]').first().click(); + await expect(page.getByRole('heading', { name: /connect wallet/i }).first()).toBeVisible(); + + await page.getByRole('button', { name: /freighter/i }).click(); + await page.getByRole('button', { name: /^connect wallet$/i }).last().click(); + + await expect(page.getByRole('heading', { name: /verify ownership/i })).toBeVisible(); + await page.getByRole('button', { name: /sign & verify/i }).click(); + + await expect(page.getByText(/GCLN3QY2X7V4R5J6K8M9P0Q1R2S3T4U5V6W7X8Y9Z0A1B2C3D4E5F6G7H8J9K/i)).toBeVisible(); + }); +});