diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index fca0f0d8..208b6ee3 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -13,6 +13,29 @@ on: jobs: build-and-test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: chainforge_test + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d chainforge_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -34,8 +57,20 @@ jobs: - name: Lint run: pnpm --filter backend run lint - - name: Test - run: pnpm --filter backend run test + - name: Prepare coverage database + working-directory: app/backend + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/chainforge_test?schema=public + run: pnpm exec prisma db push --skip-generate + + - name: Test with coverage + env: + API_KEY: test-api-key-123 + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/chainforge_test?schema=public + NODE_ENV: test + REDIS_HOST: 127.0.0.1 + REDIS_PORT: '6379' + run: pnpm --filter backend run test:cov - name: Build run: pnpm --filter backend run build @@ -43,7 +78,7 @@ jobs: - name: Export OpenAPI spec working-directory: app/backend env: - DATABASE_URL: file:./prisma/dev.db + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/chainforge_test?schema=public NODE_ENV: development run: npm run spec:export diff --git a/.github/workflows/testnet-smoke.yml b/.github/workflows/testnet-smoke.yml index d024f672..b1cdd6b2 100644 --- a/.github/workflows/testnet-smoke.yml +++ b/.github/workflows/testnet-smoke.yml @@ -13,11 +13,29 @@ on: - 'release/**' jobs: + check-configuration: + runs-on: ubuntu-latest + outputs: + configured: ${{ steps.check.outputs.configured }} + steps: + - name: Check testnet configuration + id: check + env: + SOROBAN_CONTRACT_ID: ${{ secrets.SOROBAN_CONTRACT_ID }} + SOROBAN_RPC_URL: ${{ secrets.SOROBAN_RPC_URL }} + run: | + if [[ -n "$SOROBAN_CONTRACT_ID" && -n "$SOROBAN_RPC_URL" ]]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + fi + integration-harness: name: Testnet Integration Harness runs-on: ubuntu-latest + needs: check-configuration if: > - github.event_name == 'workflow_dispatch' || (secrets.SOROBAN_CONTRACT_ID != '' && secrets.SOROBAN_RPC_URL != '') + github.event_name == 'workflow_dispatch' || needs.check-configuration.outputs.configured == 'true' steps: - name: Checkout uses: actions/checkout@v4 diff --git a/app/backend/package.json b/app/backend/package.json index f09a435a..d3e66fdc 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -23,7 +23,8 @@ "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", - "test:cov": "jest --coverage", + "test:cov": "jest --config ./test/jest-coverage.js --coverage", + "coverage:baseline": "node ./test/generate-coverage-baseline.js", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e:ci": "prisma migrate deploy && jest --config ./test/jest-e2e.json --runInBand", @@ -109,9 +110,27 @@ "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": [ - "**/*.(t|j)s" + "src/**/*.(t|j)s", + "cache/**/*.(t|j)s", + "!src/**/*.spec.ts", + "!src/**/examples/**", + "!src/main.ts", + "!src/**/*.module.ts", + "!src/**/*.dto.ts", + "!src/**/dto/**", + "!src/**/types/**", + "!src/**/*.d.ts", + "!src/**/index.ts" ], "coverageDirectory": "../coverage", + "coverageThreshold": { + "global": { + "lines": 80, + "branches": 70, + "functions": 80, + "statements": 80 + } + }, "testEnvironment": "node", "moduleNameMapper": { "^src/(.*)$": "/src/$1" diff --git a/app/backend/test/coverage-baseline.json b/app/backend/test/coverage-baseline.json new file mode 100644 index 00000000..9109d1cf --- /dev/null +++ b/app/backend/test/coverage-baseline.json @@ -0,0 +1,131 @@ +[ + ["cache/redis.service.ts",-24,-8,-6,-25], + ["src/aid/aid.controller.ts",-5,-6,-5,-5], + ["src/aid/aid.service.ts",-12,-6,-4,-12], + ["src/analytics/analytics.controller.ts",-9,-4,-3,-9], + ["src/analytics/analytics.service.ts",-42,-46,-13,-42], + ["src/analytics/privacy.service.ts",-17,-12,-1,-18], + ["src/api-keys/api-keys.controller.ts",-8,-9,-4,-8], + ["src/api-keys/api-keys.service.ts",-3,-7,100,-4], + ["src/app.controller.ts",-1,-1,-1,-1], + ["src/app.service.ts",100,100,100,100], + ["src/audit/audit.controller.ts",100,-7,100,100], + ["src/audit/audit.service.ts",-5,-17,-2,-13], + ["src/auth/roles.decorator.ts",100,100,100,100], + ["src/auth/roles.guard.ts",100,-1,100,100], + ["src/campaigns/campaigns.controller.ts",-29,-16,-8,-29], + ["src/campaigns/campaigns.service.ts",-30,-41,-6,-36], + ["src/claims/cancel-and-reissue.service.ts",-76,-51,-10,-78], + ["src/claims/claim-export.controller.ts",-8,-2,-1,-8], + ["src/claims/claim-lifecycle.controller.ts",-10,-12,-9,-10], + ["src/claims/claim-receipt.controller.ts",-2,-4,-2,-2], + ["src/claims/claim.events.ts",100,100,100,100], + ["src/claims/claims.service.ts",-64,-88,-11,-68], + ["src/common/budget/budget.service.ts",100,-1,100,100], + ["src/common/constants/api-version.constants.ts",-8,-2,-3,-8], + ["src/common/decorators/deprecated.decorator.ts",-2,-6,100,-2], + ["src/common/decorators/http-cache.decorator.ts",-3,100,-3,-3], + ["src/common/decorators/public.decorator.ts",100,100,100,100], + ["src/common/encryption/encryption.service.ts",-11,-1,-2,-11], + ["src/common/filters/http-exception.filter.ts",-5,-12,-5,-7], + ["src/common/guards/adaptive-rate-limit.guard.ts",-6,-14,100,-10], + ["src/common/guards/api-key.guard.ts",100,-4,100,100], + ["src/common/guards/org-ownership.guard.ts",100,100,100,100], + ["src/common/guards/webhook-hmac.guard.ts",-20,-20,-1,-20], + ["src/common/hmac/hmac.service.ts",-8,-3,-3,-8], + ["src/common/interceptors/deprecation.interceptor.ts",-12,-12,100,-12], + ["src/common/interceptors/http-cache.interceptor.ts",-6,-23,100,-7], + ["src/common/interceptors/logging.interceptor.ts",-16,-8,-4,-18], + ["src/common/interceptors/request-id.interceptor.ts",-11,-2,-2,-13], + ["src/common/security/csp-report.controller.ts",-2,-1,-1,-2], + ["src/common/services/internal-notes.service.ts",-5,-2,-2,-5], + ["src/common/utils/correlation-id.util.ts",-3,-4,-2,-3], + ["src/common/utils/env-loader.ts",100,-2,100,100], + ["src/common/utils/json-canonicalize.util.ts",-1,-3,100,-1], + ["src/controllers/api_keys_controller.js",-14,100,-4,-14], + ["src/deployment-metadata/deployment-metadata.controller.ts",-25,-16,-7,-25], + ["src/deployment-metadata/deployment-metadata.service.ts",100,-7,100,100], + ["src/entity-linking/entity-linking.controller.ts",-9,-7,-7,-9], + ["src/entity-linking/entity-linking.service.ts",-46,-57,-1,-46], + ["src/evidence/evidence.controller.ts",-18,-29,-5,-18], + ["src/evidence/evidence.service.ts",-50,-23,-6,-54], + ["src/evidence/file-validation.ts",-1,-5,100,-2], + ["src/evidence/fingerprint.service.ts",-38,-21,-9,-44], + ["src/evidence/upload-session.controller.ts",-14,-31,-4,-14], + ["src/evidence/upload-session.service.ts",-1,-7,100,-2], + ["src/handlers/transaction.ts",-10,-2,-2,-12], + ["src/health/health.controller.ts",-14,-21,-4,-14], + ["src/health/health.service.ts",-34,-28,-5,-34], + ["src/idempotency/error.ts",-12,100,-5,-12], + ["src/idempotency/fingerprint.ts",-16,-6,-6,-17], + ["src/idempotency/key.ts",-17,-8,-3,-17], + ["src/idempotency/middleware.ts",-32,-18,-4,-32], + ["src/idempotency/store.ts",-11,-4,-4,-11], + ["src/interceptors/idempotency.interceptor.ts",-19,-8,-4,-21], + ["src/interceptors/logging.interceptor.ts",100,-1,100,100], + ["src/jobs/dlq.service.ts",-6,-7,-1,-6], + ["src/jobs/jobs.controller.ts",-10,-11,-5,-11], + ["src/logger/log-redaction.util.ts",-11,-7,-2,-11], + ["src/logger/logger.service.ts",-13,-34,-2,-13], + ["src/middleware/correlation-propagation.util.ts",-5,-1,-2,-5], + ["src/middleware/idempotency.ts",-28,-9,-4,-28], + ["src/middleware/request-correlation.middleware.ts",100,-1,100,100], + ["src/notifications/email/email.service.ts",100,-3,100,100], + ["src/notifications/interfaces/notification-job.interface.ts",100,100,100,100], + ["src/notifications/notifications.processor.ts",-2,-15,100,-2], + ["src/notifications/notifications.service.ts",100,-3,100,100], + ["src/notifications/outbox.controller.ts",100,-1,100,100], + ["src/notifications/providers/mock-sms.provider.ts",100,100,100,100], + ["src/notifications/providers/sms-provider.interface.ts",100,100,100,100], + ["src/notifications/providers/twilio-sms.provider.ts",100,100,100,100], + ["src/observability/entities/observability.entity.ts",-1,100,100,-1], + ["src/observability/health/health.controller.ts",-37,-26,-12,-42], + ["src/observability/health/health.service.ts",-40,-16,-4,-42], + ["src/observability/metrics/metrics.controller.ts",100,100,100,100], + ["src/observability/metrics/metrics.middleware.ts",100,-2,100,100], + ["src/observability/metrics/metrics.providers.ts",100,100,100,100], + ["src/observability/metrics/metrics.service.ts",-24,-29,-14,-24], + ["src/observability/observability.controller.ts",-5,-3,-5,-5], + ["src/observability/observability.service.ts",-5,100,-5,-5], + ["src/observability/tracing/tracing.service.ts",-18,-13,-1,-18], + ["src/onchain/aid-escrow.controller.ts",-17,-14,-1,-17], + ["src/onchain/aid-escrow.service.ts",-10,-8,-1,-10], + ["src/onchain/interfaces/onchain-job.interface.ts",100,100,100,100], + ["src/onchain/ledger-admin.controller.ts",-16,-12,-4,-16], + ["src/onchain/ledger-backfill.service.ts",-49,-27,-7,-51], + ["src/onchain/ledger-reconciliation.service.ts",-46,-41,-8,-48], + ["src/onchain/onchain.adapter.mock.ts",-11,100,-5,-11], + ["src/onchain/onchain.adapter.ts",100,100,100,100], + ["src/onchain/onchain.processor.ts",-33,-37,-3,-33], + ["src/onchain/onchain.service.ts",-12,-8,-4,-12], + ["src/onchain/soroban-onchain.adapter.ts",-84,-42,-20,-87], + ["src/onchain/soroban.adapter.ts",-197,-100,-44,-201], + ["src/onchain/utils/contract-value.ts",-14,-18,-2,-14], + ["src/onchain/utils/retry-with-timeout.ts",-14,-7,-4,-16], + ["src/onchain/utils/soroban-error.mapper.ts",-30,-36,-1,-30], + ["src/orgs/invites.controller.ts",-5,-9,-4,-5], + ["src/orgs/invites.processor.ts",-4,-3,-1,-4], + ["src/orgs/invites.service.ts",-27,-14,-4,-29], + ["src/prisma/prisma.service.ts",-3,-2,100,-3], + ["src/retention-policy/retention-policy.controller.ts",-11,-3,-9,-11], + ["src/retention-policy/retention-policy.service.ts",-25,-20,100,-25], + ["src/retention-policy/retention-purge.processor.ts",-7,-1,-2,-8], + ["src/sandbox/demo-seeds.constants.ts",100,100,100,100], + ["src/sandbox/sandbox.controller.ts",-7,-1,-5,-7], + ["src/sandbox/sandbox.guard.ts",-3,-2,-1,-3], + ["src/sandbox/seed.service.ts",-50,-13,-7,-52], + ["src/search/admin-search.controller.ts",-2,-3,-1,-2], + ["src/search/admin-search.service.ts",-19,-17,-5,-19], + ["src/services/api_keys_service.js",-12,100,-6,-13], + ["src/session/session.controller.ts",100,-8,100,100], + ["src/session/session.service.ts",-25,-34,-3,-26], + ["src/swagger.config.ts",-3,100,-1,-3], + ["src/test-error/test-error.controller.ts",-11,-1,-9,-11], + ["src/verification/enhanced-verification-flow.service.ts",-60,-36,-16,-65], + ["src/verification/verification-flow.service.ts",-1,-6,100,-1], + ["src/verification/verification-inbox.controller.ts",-15,-29,-8,-15], + ["src/verification/verification-inbox.service.ts",-21,-12,-4,-21], + ["src/verification/verification.controller.ts",-14,-15,-12,-14], + ["src/verification/verification.processor.ts",100,-8,100,100], + ["src/verification/verification.service.ts",-69,-51,-15,-69] +] diff --git a/app/backend/test/generate-coverage-baseline.js b/app/backend/test/generate-coverage-baseline.js new file mode 100644 index 00000000..1ad09195 --- /dev/null +++ b/app/backend/test/generate-coverage-baseline.js @@ -0,0 +1,35 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const backendRoot = path.resolve(__dirname, '..'); +const summaryPath = path.resolve(backendRoot, '../coverage/coverage-summary.json'); +const outputPath = path.resolve(__dirname, 'coverage-baseline.json'); +const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); +const metrics = ['lines', 'branches', 'functions', 'statements']; +const globalSentinel = 'src/auth/app-role.enum.ts'; +const uncoveredTolerance = { + 'src/health/health.service.ts': { branches: 1 }, +}; + +const entries = Object.entries(summary) + .filter(([file]) => file !== 'total') + .map(([file, coverage]) => [path.relative(backendRoot, file), coverage]) + .filter(([file]) => file.startsWith('src/') || file.startsWith('cache/')) + .filter(([file]) => file !== globalSentinel) + .sort(([left], [right]) => left.localeCompare(right)); + +const baseline = entries.map(([file, coverage]) => [ + file, + ...metrics.map(metric => { + const uncovered = coverage[metric].total - coverage[metric].covered; + const tolerance = uncoveredTolerance[file]?.[metric] ?? 0; + return uncovered === 0 && tolerance === 0 ? 100 : -(uncovered + tolerance); + }), +]); + +const output = `[\n${baseline + .map(entry => ` ${JSON.stringify(entry)}`) + .join(',\n')}\n]\n`; + +fs.writeFileSync(outputPath, output); +console.log(`Wrote a baseline for ${baseline.length} coverage files.`); diff --git a/app/backend/test/jest-coverage.js b/app/backend/test/jest-coverage.js new file mode 100644 index 00000000..2a4d995a --- /dev/null +++ b/app/backend/test/jest-coverage.js @@ -0,0 +1,33 @@ +const { jest: baseConfig } = require('../package.json'); +const coverageBaseline = require('./coverage-baseline.json'); + +const baselineThresholds = Object.fromEntries( + coverageBaseline.map( + ([file, lines, branches, functions, statements]) => [ + file, + { lines, branches, functions, statements }, + ], + ), +); + +module.exports = { + ...baseConfig, + rootDir: '..', + testRegex: [ + '.*\\.spec\\.ts$', + '.*verification-lifecycle\\.e2e-spec\\.ts$', + ], + testPathIgnorePatterns: ['/test/idempotency\\.spec\\.ts$'], + moduleNameMapper: { + ...baseConfig.moduleNameMapper, + '^cache/(.*)$': '/cache/$1', + '^@stellar/stellar-sdk$': '/test/mocks/stellar-sdk.mock.ts', + '^openai$': '/test/mocks/openai.mock.ts', + }, + coverageReporters: ['text', 'json-summary'], + coverageThreshold: { + global: baseConfig.coverageThreshold.global, + ...baselineThresholds, + }, + forceExit: true, +};