Skip to content
Merged
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
17 changes: 14 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,22 @@ Projects are configured in the PostgreSQL database (`projects` table). Each proj
### Testing

```bash
npm test # Run tests
npm run test:coverage # Run with coverage
npm run test:watch # Watch mode
npm test # Run unit tests (all 4 unit projects)
npm run test:unit # Alias for npm test
npm run test:integration # Run integration tests (requires DB — see below)
npm run test:all # Run unit + integration tests together
npm run test:coverage # Coverage report (unit tests)
npm run test:watch # Watch mode (unit tests)
```

> **Do not use `npm test -- --project integration`** — it _adds_ the integration project on top of the hardcoded unit project flags, running all 5 projects instead of filtering. Use `npm run test:integration` instead.

Integration tests require a PostgreSQL database. They find it via (in order):
1. `TEST_DATABASE_URL` env var
2. `TEST_DATABASE_URL` in `.cascade/env` (written by `.cascade/setup.sh`)
3. Docker Compose default at `127.0.0.1:5433` (`npm run test:db:up`)
4. Container IP of `cascade-postgres-test`

### Linting

```bash
Expand Down
7 changes: 7 additions & 0 deletions src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import * as schema from './schema/index.js';

let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
let pool: pg.Pool | null = null;
let _testDbOverride: ReturnType<typeof drizzle<typeof schema>> | null = null;

/** Test-only: override the DB instance returned by getDb(). */
export function _setTestDb(db: ReturnType<typeof drizzle<typeof schema>> | null): void {
_testDbOverride = db;
}

function getDatabaseUrl(): string {
if (process.env.DATABASE_URL) {
Expand All @@ -23,6 +29,7 @@ function getDatabaseUrl(): string {
}

export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
if (_testDbOverride) return _testDbOverride;
if (!db) {
pool = new pg.Pool({
connectionString: getDatabaseUrl(),
Expand Down
51 changes: 51 additions & 0 deletions tests/docker/worker-setup-test/run-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Tests whether .cascade/setup.sh inside a worker container provides enough
# infrastructure to run the full test suite (unit + integration tests).
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

# Use the latest available worker image
WORKER_IMAGE="${WORKER_IMAGE:-ghcr.io/zbigniewsobiecki/cascade-worker:923f7c6215608865ac55e4d89f83663f055ab87a}"

echo "=== Worker Setup Test ==="
echo "Project root : $PROJECT_ROOT"
echo "Worker image : $WORKER_IMAGE"
echo ""

docker run --rm \
--name cascade-worker-setup-test \
-v "$PROJECT_ROOT:/workspace/cascade" \
-e AGENT_PROFILE_NAME=implementation \
-e CI=true \
"$WORKER_IMAGE" \
bash -c '
set -e
echo "--- Starting inside worker container ---"
echo "User: $(id)"
echo "Node: $(node --version)"
echo "npm: $(npm --version)"
echo ""

cd /workspace/cascade

# Run the setup script (installs + starts PostgreSQL and Redis, creates DBs,
# writes TEST_DATABASE_URL to .cascade/env, runs migrations)
echo "--- Running .cascade/setup.sh ---"
bash .cascade/setup.sh
echo ""

# Verify .cascade/env has the test DB URL
echo "--- .cascade/env contents ---"
cat .cascade/env
echo ""

# Run unit tests
echo "--- Running unit tests ---"
npm test 2>&1

echo ""
echo "--- Running integration tests ---"
npm run test:integration 2>&1
'
6 changes: 5 additions & 1 deletion tests/integration/github-personas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* modes with real DB-backed project configurations.
*/

import { beforeEach, describe, expect, it } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { findProjectByRepoFromDb } from '../../src/db/repositories/configRepository.js';
import { resolveIntegrationCredential } from '../../src/db/repositories/credentialsRepository.js';
import {
Expand Down Expand Up @@ -91,6 +91,10 @@ function makeReviewRequestedPayload(requestedReviewer: string, prAuthor: string)
// Tests
// ============================================================================

beforeAll(async () => {
await truncateAll();
});

describe('GitHub Dual-Persona System (integration)', () => {
beforeEach(async () => {
await truncateAll();
Expand Down
32 changes: 31 additions & 1 deletion tests/integration/helpers/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { closeDb, getDb } from '../../../src/db/client.js';
import { _setTestDb, closeDb, getDb } from '../../../src/db/client.js';

function checkPortReachable(host: string, port: number, timeoutMs = 500): Promise<boolean> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -130,3 +130,33 @@ export async function truncateAll() {
export async function closeTestDb() {
await closeDb();
}

const ROLLBACK = Symbol('TEST_ROLLBACK');

/**
* Wraps a test body in a transaction that is always rolled back.
* Use this instead of truncateAll() for faster, isolated integration tests.
*
* Usage:
* it('does something', withTestTransaction(async () => {
* await seedOrg();
* // ... assertions ...
* }));
*/
export function withTestTransaction(fn: () => Promise<void>): () => Promise<void> {
return async () => {
try {
await getDb().transaction(async (tx) => {
_setTestDb(tx as ReturnType<typeof getDb>);
try {
await fn();
} finally {
_setTestDb(null);
}
throw ROLLBACK; // always roll back
});
} catch (e) {
if (e !== ROLLBACK) throw e;
}
};
}
6 changes: 5 additions & 1 deletion tests/integration/integration-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* Unit tests (mocked) are in tests/unit/triggers/shared/integration-validation.test.ts
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasScmIntegration, hasScmPersonaToken } from '../../src/github/integration.js';
import { hasPmIntegration } from '../../src/pm/integration.js';
import {
Expand Down Expand Up @@ -41,6 +41,10 @@ vi.mock('../../src/utils/logging.js', () => ({
},
}));

beforeAll(async () => {
await truncateAll();
});

describe('Integration Validation (integration)', () => {
beforeEach(async () => {
await truncateAll();
Expand Down
6 changes: 5 additions & 1 deletion tests/integration/multi-provider-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* tests/integration/db/credentialResolution.test.ts.
*/

import { beforeEach, describe, expect, it } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { resolveIntegrationCredential } from '../../src/db/repositories/credentialsRepository.js';
import { truncateAll } from './helpers/db.js';
import {
Expand All @@ -20,6 +20,10 @@ import {
seedProject,
} from './helpers/seed.js';

beforeAll(async () => {
await truncateAll();
});

describe('Multi-Provider Credential Isolation (integration)', () => {
beforeEach(async () => {
await truncateAll();
Expand Down
6 changes: 5 additions & 1 deletion tests/integration/pm-provider-switching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* PM provider is returned and triggers dispatch correctly.
*/

import { beforeEach, describe, expect, it } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
findProjectByBoardIdFromDb,
findProjectByJiraProjectKeyFromDb,
Expand Down Expand Up @@ -85,6 +85,10 @@ function makeJiraStatusChangedPayload(statusName: string, issueKey: string) {
// Tests
// ============================================================================

beforeAll(async () => {
await truncateAll();
});

describe('PM Provider Switching (integration)', () => {
beforeEach(async () => {
await truncateAll();
Expand Down
6 changes: 5 additions & 1 deletion tests/integration/trigger-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* project configurations (loaded via configRepository).
*/

import { beforeEach, describe, expect, it } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
findProjectByBoardIdFromDb,
findProjectByRepoFromDb,
Expand Down Expand Up @@ -81,6 +81,10 @@ function makeTrelloLabelPayload(cardId: string, labelId: string, labelName = 'Re
// Tests
// ============================================================================

beforeAll(async () => {
await truncateAll();
});

describe('Trigger Registry (integration)', () => {
beforeEach(async () => {
await truncateAll();
Expand Down
6 changes: 5 additions & 1 deletion tests/integration/webhook-logging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
* pruning are covered in tests/integration/db/webhookLogsRepository.test.ts.
*/

import { beforeEach, describe, expect, it } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
getWebhookLogById,
insertWebhookLog,
} from '../../src/db/repositories/webhookLogsRepository.js';
import { truncateAll } from './helpers/db.js';
import { seedOrg, seedProject, seedWebhookLog } from './helpers/seed.js';

beforeAll(async () => {
await truncateAll();
});

describe('Webhook Logging — Provider-Specific (integration)', () => {
beforeEach(async () => {
await truncateAll();
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/db/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest';
import { _setTestDb, getDb } from '../../../src/db/client.js';

/**
* Tests for the _setTestDb override mechanism in getDb().
* These tests only exercise the override path (where _testDbOverride !== null),
* so no real database connection is needed.
*/
describe('_setTestDb', () => {
afterEach(() => {
// Always clear to avoid polluting subsequent tests (isolate: false)
_setTestDb(null);
});

it('getDb() returns the override when set', () => {
const fakeDb = { __isFakeDb: true } as unknown as ReturnType<typeof getDb>;
_setTestDb(fakeDb);
expect(getDb()).toBe(fakeDb);
});

it('getDb() returns the latest override when called again', () => {
const fakeDb1 = { id: 1 } as unknown as ReturnType<typeof getDb>;
const fakeDb2 = { id: 2 } as unknown as ReturnType<typeof getDb>;
_setTestDb(fakeDb1);
_setTestDb(fakeDb2);
expect(getDb()).toBe(fakeDb2);
});

it('override takes precedence over any cached real db', () => {
// Arrange: set an initial override (simulates prior state)
const initialDb = { initial: true } as unknown as ReturnType<typeof getDb>;
_setTestDb(initialDb);
expect(getDb()).toBe(initialDb);

// Act: swap to a different override
const newDb = { new: true } as unknown as ReturnType<typeof getDb>;
_setTestDb(newDb);

// Assert: new override wins
expect(getDb()).toBe(newDb);
});
});
84 changes: 84 additions & 0 deletions tests/unit/integration-helpers/withTestTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

const { mockSetTestDb, mockTransaction } = vi.hoisted(() => ({
mockSetTestDb: vi.fn(),
mockTransaction: vi.fn(),
}));

vi.mock('../../../src/db/client.js', () => ({
_setTestDb: mockSetTestDb,
getDb: vi.fn(() => ({ transaction: mockTransaction })),
closeDb: vi.fn(),
}));

import { withTestTransaction } from '../../integration/helpers/db.js';

/**
* Unit tests for withTestTransaction helper.
* Verifies rollback-on-success, error propagation, and _setTestDb lifecycle.
*/
describe('withTestTransaction', () => {
afterEach(() => {
mockSetTestDb.mockReset();
mockTransaction.mockReset();
});

it('calls fn() inside a transaction', async () => {
mockTransaction.mockImplementation(async (callback: (tx: unknown) => Promise<void>) => {
await callback({});
});
const fn = vi.fn().mockResolvedValue(undefined);

await withTestTransaction(fn)();

expect(fn).toHaveBeenCalledOnce();
});

it('passes the tx object to _setTestDb before fn and null after', async () => {
const txMock = { tx: true };
const calls: unknown[] = [];
mockTransaction.mockImplementation(async (callback: (tx: unknown) => Promise<void>) => {
await callback(txMock);
});
mockSetTestDb.mockImplementation((db: unknown) => calls.push(db));

await withTestTransaction(vi.fn().mockResolvedValue(undefined))();

expect(calls).toEqual([txMock, null]);
});

it('calls _setTestDb(null) in finally even when fn throws', async () => {
const txMock = { tx: true };
mockTransaction.mockImplementation(async (callback: (tx: unknown) => Promise<void>) => {
await callback(txMock);
});
const error = new Error('fn error');

await expect(withTestTransaction(vi.fn().mockRejectedValue(error))()).rejects.toThrow(
'fn error',
);

expect(mockSetTestDb).toHaveBeenLastCalledWith(null);
});

it('does not throw when fn succeeds (ROLLBACK sentinel is swallowed)', async () => {
mockTransaction.mockImplementation(async (callback: (tx: unknown) => Promise<void>) => {
await callback({});
});

await expect(
withTestTransaction(vi.fn().mockResolvedValue(undefined))(),
).resolves.toBeUndefined();
});

it('re-throws non-ROLLBACK errors from fn', async () => {
mockTransaction.mockImplementation(async (callback: (tx: unknown) => Promise<void>) => {
await callback({});
});
const error = new Error('fn failed');

await expect(withTestTransaction(vi.fn().mockRejectedValue(error))()).rejects.toThrow(
'fn failed',
);
});
});
Loading