Skip to content
Open
145 changes: 145 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
name: Integration Tests

on:
workflow_dispatch:
inputs:
preview_url:
description: "Preview environment URL"
required: true
type: string
pr_number:
description: "PR number"
required: true
type: string

jobs:
test-group-1:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2

- name: Fetch Infisical Secrets
uses: Infisical/secrets-action@v1.0.9
with:
method: "universal"
client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }}
project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }}
env-slug: "test"
export-type: "env"

- name: Install dependencies
run: bun install

- name: Create test results directory
run: mkdir -p server/test-results

- name: Run Test Group 1 (Balances)
env:
SERVER_URL: ${{ inputs.preview_url }}
run: ./scripts/testGroups/g1.sh

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-g1-${{ inputs.pr_number }}
path: server/test-results/
retention-days: 7

test-group-2:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2

- name: Fetch Infisical Secrets
uses: Infisical/secrets-action@v1.0.9
with:
method: "universal"
client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }}
project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }}
env-slug: "test"
export-type: "env"

- name: Install dependencies
run: bun install

- name: Create test results directory
run: mkdir -p server/test-results

- name: Run Test Group 2 (Attach)
env:
SERVER_URL: ${{ inputs.preview_url }}
run: ./scripts/testGroups/g2.sh

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-g2-${{ inputs.pr_number }}
path: server/test-results/
retention-days: 7

cleanup-webhook:
needs: [test-group-1, test-group-2]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2

- name: Fetch Infisical Secrets
uses: Infisical/secrets-action@v1.0.9
with:
method: "universal"
client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }}
project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }}
env-slug: "test"
export-type: "env"

- name: Install dependencies
run: bun install

- name: Cleanup Stripe webhook
env:
SERVER_URL: ${{ inputs.preview_url }}
run: bun --cwd scripts preview/cleanupPreviewWebhook.ts

report-status:
needs: [test-group-1, test-group-2]
if: always()
runs-on: ubuntu-latest
steps:
- name: Report to PR
uses: actions/github-script@v7
with:
script: |
const g1Status = '${{ needs.test-group-1.result }}';
const g2Status = '${{ needs.test-group-2.result }}';
const allPassed = g1Status === 'success' && g2Status === 'success';

const body = allPassed
? 'Integration tests passed!\n- Group 1 (Balances): passed\n- Group 2 (Attach): passed'
: `Integration tests failed!\n- Group 1 (Balances): ${g1Status === 'success' ? 'passed' : 'failed'}\n- Group 2 (Attach): ${g2Status === 'success' ? 'passed' : 'failed'}`;

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ inputs.pr_number }},
body
});
98 changes: 98 additions & 0 deletions .github/workflows/preview-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Preview Deploy

on:
pull_request:
branches: [main, dev]
types: [opened, synchronize, reopened]

jobs:
deploy-preview:
runs-on: ubuntu-latest
outputs:
preview_url: ${{ steps.deploy.outputs.service_domain }}
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2

- name: Fetch Infisical Secrets
uses: Infisical/secrets-action@v1.0.9
with:
method: "universal"
client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }}
project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }}
env-slug: "test"
export-type: "env"

- name: Install dependencies
run: bun install

- name: Run database migrations
run: bun run db:push

- name: Setup test organization
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: bun --cwd scripts preview/setupTestOrgCI.ts

- name: Deploy to Railway Preview
id: deploy
uses: ayungavis/railway-preview-deploy@v1.0.2
with:
railway_api_token: ${{ secrets.RAILWAY_API_TOKEN }}
project_id: ${{ secrets.RAILWAY_PROJECT_ID }}
environment_name: 'development'
preview_environment_name: 'pr-${{ github.event.pull_request.number }}'
branch_name: ${{ github.head_ref }}

- name: Setup Stripe webhook for preview URL
env:
SERVER_URL: https://${{ steps.deploy.outputs.service_domain }}
run: bun --cwd scripts preview/setupPreviewWebhook.ts

- name: Wait for deployment to be healthy
run: |
DEPLOY_URL="https://${{ steps.deploy.outputs.service_domain }}"
echo "Railway deploy output domain: ${{ steps.deploy.outputs.service_domain }}"
echo "Waiting for $DEPLOY_URL to be healthy..."

for i in {1..30}; do
if curl -sf "$DEPLOY_URL/health" > /dev/null 2>&1; then
echo "Preview is healthy!"
exit 0
fi
echo "Attempt $i/30 - waiting 20s..."
sleep 20
done

echo "Preview failed to become healthy"
exit 1

- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview deployed: https://${{ steps.deploy.outputs.service_domain }}`
})

- name: Trigger Integration Tests
uses: actions/github-script@v7
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'integration-tests.yml',
ref: '${{ github.head_ref }}',
inputs: {
preview_url: 'https://${{ steps.deploy.outputs.service_domain }}',
pr_number: '${{ github.event.pull_request.number }}'
}
})
12 changes: 12 additions & 0 deletions railway.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "docker/Dockerfile"
},
"deploy": {
"startCommand": "bun start",
"healthcheckPath": "/health",
"healthcheckTimeout": 300
}
}
4 changes: 3 additions & 1 deletion scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"ink": "^5.1.0",
"inquirer": "^12.6.3",
"p-limit": "^7.2.0",
"react": "^18.3.1"
"postgres": "^3.4.8",
"react": "^18.3.1",
"stripe": "catalog:"
},
"devDependencies": {
"tsx": "^4.19.2",
Expand Down
41 changes: 41 additions & 0 deletions scripts/preview/cleanupPreviewWebhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import "dotenv/config";
import Stripe from "stripe";

const main = async () => {
const serverUrl = process.env.SERVER_URL;
const orgId = process.env.TESTS_ORG_ID;
const stripeSecretKey = process.env.STRIPE_SANDBOX_SECRET_KEY;

if (!serverUrl || !orgId) {
console.log("SERVER_URL or TESTS_ORG_ID not set, skipping cleanup");
return;
}

if (!stripeSecretKey) {
console.log("STRIPE_SANDBOX_SECRET_KEY not set, skipping cleanup");
return;
}

const stripe = new Stripe(stripeSecretKey);
const webhookUrl = `${serverUrl}/webhooks/connect/sandbox?org_id=${orgId}`;

// Find and delete the webhook
const existing = await stripe.webhookEndpoints.list();
const webhook = existing.data.find(
(webhookEndpoint) => webhookEndpoint.url === webhookUrl,
);

if (webhook) {
await stripe.webhookEndpoints.del(webhook.id);
console.log(`Deleted webhook: ${webhook.id}`);
} else {
console.log("No matching webhook found to delete");
}
};

main()
.then(() => process.exit(0))
.catch((error) => {
console.error("Cleanup failed:", error);
process.exit(1);
});
71 changes: 71 additions & 0 deletions scripts/preview/setupPreviewWebhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import "dotenv/config";
import { WEBHOOK_EVENTS } from "@server/utils/constants.js";
import { encryptData } from "@server/utils/encryptUtils.js";
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import Stripe from "stripe";
import { TEST_ORG_CONFIG } from "../setupTestUtils/createTestOrg.js";

const main = async () => {
const serverUrl = process.env.SERVER_URL;
const orgId = process.env.TESTS_ORG_ID || TEST_ORG_CONFIG.id;
const stripeSecretKey = process.env.STRIPE_SANDBOX_SECRET_KEY;
const databaseUrl = process.env.DATABASE_URL;

if (!serverUrl) throw new Error("SERVER_URL not set");
if (!stripeSecretKey) throw new Error("STRIPE_SANDBOX_SECRET_KEY not set");
if (!databaseUrl) throw new Error("DATABASE_URL not set");

const stripe = new Stripe(stripeSecretKey);

// Webhook URL includes org_id query param (used by stripeConnectSeederMiddleware)
const webhookUrl = `${serverUrl}/webhooks/connect/sandbox?org_id=${orgId}`;

// Check if webhook already exists for this URL
const existing = await stripe.webhookEndpoints.list();
const existingWebhook = existing.data.find(
(webhook) => webhook.url === webhookUrl,
);

if (existingWebhook) {
console.log(`Webhook already exists: ${existingWebhook.id}`);
return;
}

// Create webhook endpoint
const webhook = await stripe.webhookEndpoints.create({
url: webhookUrl,
enabled_events:
WEBHOOK_EVENTS as Stripe.WebhookEndpointCreateParams.EnabledEvent[],
connect: true,
});

console.log(`Created webhook: ${webhook.id}`);

// Update test org's CONNECT webhook secret in database
// This is read by getConnectWebhookSecret() in initStripeCli.ts
const db = drizzle(postgres(databaseUrl));

if (!webhook.secret) throw new Error("Webhook secret not returned by Stripe");
const encryptedSecret = encryptData(webhook.secret);

await db.execute(sql`
UPDATE organizations
SET stripe_config = jsonb_set(
COALESCE(stripe_config, '{}'),
'{test_connect_webhook_secret}',
${JSON.stringify(encryptedSecret)}::jsonb
)
WHERE id = ${orgId}
`);

console.log(`Connect webhook secret updated for org ${orgId}`);
};

main()
.then(() => process.exit(0))
.catch((error) => {
console.error("Setup failed:", error);
process.exit(1);
});
Loading
Loading